feat(host): the lease and MCP channels as named pipes with the SID check, and the host, bridge and commands on Windows (P11c)
Windows steps 3 of D-62. Every `cfg(unix)` gate on the host, the bridge, the opener, the books server and the command line is gone: they build on every OS, and the Unix behaviour is unchanged (the existing over-the-wire suites pass on Linux). `ledger_mcp::ipc` is the one seam for the channels. Unix: the same 0600 sockets as before, peer uid unchanged (the peer functions moved here). Windows: a named pipe `\\.\pipe\wanderbooks-<hash of the root's run/>-<name>` made with a DACL of one allow entry for this account's SID, remote clients refused, the first instance made once (a second server is refused, as a held lock refuses it on Unix), and 16 idle instances kept waiting so a client never finds all busy while the server makes the next. Where Unix reads `SO_PEERCRED`, each end of a pipe takes the other's process id (`GetNamedPipeClientProcessId`/`...ServerProcessId`) and compares that process's token user with its own SID; any doubt is "not us". Clients open with SECURITY_IDENTIFICATION so a pipe server cannot act as the client. A blocking client with a read limit is polled with PeekNamedPipe (a pipe read cannot be cut short), and a pipe that has gone reads as the end of the stream, as a closed socket does. Stop signals are Ctrl+C, Ctrl+Break, console close and shutdown where Unix has SIGTERM, SIGINT and SIGHUP. The host starts detached (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, leaving the opener's job when the job lets it: an agent's job must not take the host down, as an app's systemd scope must not on Linux) with a fixed environment; `close_range` and `seccomp` stay Linux-only (D-32). A replaced program is told by size and last write. Git for Windows is found and a quiet git is stopped with its tree (`taskkill /T`). The test harness of ledger-cli finds its root by `default_root`, cleans a child's environment per OS and reaches the server over its pipe; the bridge's scripted-server tests stay Unix-only. No new dependency (tokio's named pipes and reqwest's `windows_named_pipe` were already in the build); `windows-sys` stays in ledger-store. Tests: ledger-mcp `ipc::tests` (four on every OS, one Windows only), ledger-host `tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`, Windows variants of the host and start tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
5f5dd8f7da
commit
bd6448532e
21 files changed
+1471
-410
No files matched your search
@@ -162,8 +162,8 @@ struct Bridge {
|
||||
next_id: std::sync::atomic::AtomicU64,
|
||||
/// The agent's requests not yet answered, by id (INVARIANT 8).
|
||||
pending: Mutex<std::collections::HashMap<String, Pending>>,
|
||||
/// The server's socket, for its owner check (INVARIANT 9).
|
||||
socket: PathBuf,
|
||||
/// The server's channel, for its owner check (INVARIANT 9).
|
||||
endpoint: ledger_mcp::ipc::Endpoint,
|
||||
}
|
||||
|
||||
/// Who has the server's socket, when it isn't plainly this account.
|
||||
@@ -1034,9 +1034,9 @@ impl Bridge {
|
||||
|
||||
/// Whether the server's socket belongs to this account (INVARIANT 9).
|
||||
fn socket_is_ours(&self) -> Result<(), Owner> {
|
||||
let stream = std::os::unix::net::UnixStream::connect(&self.socket)
|
||||
.map_err(|_| Owner::Unreachable)?;
|
||||
if ledger_mcp::local::peer_is_us(&stream) {
|
||||
let stream =
|
||||
ledger_mcp::ipc::connect_std(&self.endpoint).map_err(|_| Owner::Unreachable)?;
|
||||
if ledger_mcp::ipc::peer_is_us(&stream) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Owner::Other(
|
||||
@@ -1314,18 +1314,16 @@ pub fn run(
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_string();
|
||||
let opener = Opener::new(root.as_deref(), knobs, Kind::Bridge, Some(book.clone()))?;
|
||||
// Kept for the whole session: the client reaches the socket by it.
|
||||
let socket =
|
||||
ledger_store::root::SocketPath::new(opener.run_dir(), ledger_mcp::local::MCP_SOCKET)
|
||||
.map_err(|e| format!("can't reach the books server: {e}"))?;
|
||||
// Kept for the whole session: the client reaches the channel by it.
|
||||
let endpoint = ledger_mcp::ipc::Endpoint::new(opener.run_dir(), ledger_mcp::local::MCP_SOCKET)
|
||||
.map_err(|e| format!("can't reach the books server: {e}"))?;
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let result = rt.block_on(async move {
|
||||
// A connection per call: one the server closes while idle can't
|
||||
// swallow a call (INVARIANT 4).
|
||||
let http = reqwest::Client::builder()
|
||||
.unix_socket(socket.path())
|
||||
let http = crate::channel::http_builder(&endpoint)
|
||||
.pool_max_idle_per_host(0)
|
||||
.no_proxy()
|
||||
.build()
|
||||
@@ -1347,7 +1345,7 @@ pub fn run(
|
||||
tools_seen: Mutex::new(None),
|
||||
next_id: std::sync::atomic::AtomicU64::new(1),
|
||||
pending: Mutex::new(std::collections::HashMap::new()),
|
||||
socket: socket.path().to_path_buf(),
|
||||
endpoint,
|
||||
});
|
||||
let keeper = tokio::spawn(bridge.clone().keep(opener));
|
||||
let writer = tokio::spawn(async move {
|
||||
@@ -1363,12 +1361,7 @@ pub fn run(
|
||||
// A client that stops the bridge with a signal (most do) still has
|
||||
// its session ended upstream (INVARIANT 10).
|
||||
let stop = Arc::new(Notify::new());
|
||||
for kind in [
|
||||
tokio::signal::unix::SignalKind::terminate(),
|
||||
tokio::signal::unix::SignalKind::interrupt(),
|
||||
tokio::signal::unix::SignalKind::hangup(),
|
||||
] {
|
||||
let mut signals = tokio::signal::unix::signal(kind).map_err(|e| e.to_string())?;
|
||||
for mut signals in ledger_mcp::ipc::stop_signals().map_err(|e| e.to_string())? {
|
||||
let stop = stop.clone();
|
||||
tokio::spawn(async move {
|
||||
if signals.recv().await.is_some() {
|
||||
@@ -1537,7 +1530,7 @@ mod tests {
|
||||
|
||||
/// The bridge against a scripted server on a Unix socket: every way an
|
||||
/// answer can come back, and the bridge's own bookkeeping.
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod against_a_server {
|
||||
use super::*;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -1669,8 +1662,12 @@ mod against_a_server {
|
||||
}
|
||||
|
||||
fn bridge_at(socket: &Path, knobs: Knobs) -> (Arc<Bridge>, mpsc::UnboundedReceiver<Value>) {
|
||||
let http = reqwest::Client::builder()
|
||||
.unix_socket(socket)
|
||||
let endpoint = ledger_mcp::ipc::Endpoint::new(
|
||||
socket.parent().unwrap(),
|
||||
socket.file_name().and_then(|n| n.to_str()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let http = crate::channel::http_builder(&endpoint)
|
||||
.pool_max_idle_per_host(0)
|
||||
.no_proxy()
|
||||
.build()
|
||||
@@ -1693,7 +1690,7 @@ mod against_a_server {
|
||||
tools_seen: Mutex::new(None),
|
||||
next_id: std::sync::atomic::AtomicU64::new(1),
|
||||
pending: Mutex::new(std::collections::HashMap::new()),
|
||||
socket: socket.to_path_buf(),
|
||||
endpoint,
|
||||
});
|
||||
(bridge, rx)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//! The HTTP client for the books server's channel (plan 3.9, D-62): a
|
||||
//! Unix socket on Unix, a named pipe on Windows. The server speaks HTTP
|
||||
//! on it; only how a client reaches it differs.
|
||||
|
||||
use ledger_mcp::ipc::Endpoint;
|
||||
|
||||
/// A client builder that connects to `endpoint` whatever URL it is given
|
||||
/// (`http://localhost/...`). On Unix the endpoint must outlive the client:
|
||||
/// a long socket path is reached through a handle it keeps open.
|
||||
pub fn http_builder(endpoint: &Endpoint) -> reqwest::ClientBuilder {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
reqwest::Client::builder().unix_socket(endpoint.path().to_path_buf())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
reqwest::Client::builder().windows_named_pipe(endpoint.pipe_name().to_string())
|
||||
}
|
||||
}
|
||||
@@ -302,12 +302,7 @@ pub fn serve(
|
||||
// Ctrl-C, SIGTERM and SIGHUP are heard from the start, whatever
|
||||
// serve is doing (opening, waiting to try again): none is missed.
|
||||
let stop = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
for kind in [
|
||||
tokio::signal::unix::SignalKind::interrupt(),
|
||||
tokio::signal::unix::SignalKind::terminate(),
|
||||
tokio::signal::unix::SignalKind::hangup(),
|
||||
] {
|
||||
let mut signals = tokio::signal::unix::signal(kind)?;
|
||||
for mut signals in ledger_mcp::ipc::stop_signals()? {
|
||||
let stop = stop.clone();
|
||||
tokio::spawn(async move {
|
||||
while signals.recv().await.is_some() {
|
||||
@@ -872,8 +867,13 @@ fn offer_upgrades(run: &Path) {
|
||||
/// or a job in the background, would wait unseen for an answer, and mix
|
||||
/// the question into its output).
|
||||
fn offer_upgrade(run: &Path, book: &str, waiting: &admin::FormatWaiting) {
|
||||
// Windows has no foreground process group to ask about: a terminal
|
||||
// is all there is to see.
|
||||
#[cfg(unix)]
|
||||
// SAFETY: isatty, tcgetpgrp and getpgrp have no preconditions.
|
||||
let seen = unsafe { libc::isatty(1) == 1 && libc::tcgetpgrp(0) == libc::getpgrp() };
|
||||
#[cfg(windows)]
|
||||
let seen = std::io::IsTerminal::is_terminal(&std::io::stdout());
|
||||
if !(seen && can_type_a_passphrase()) {
|
||||
eprintln!(
|
||||
"note: the books \"{book}\" wait for a format upgrade (to {}): until the owner agrees, with `wanderbooks books upgrade {book}` at a terminal or in the app, they work on their old format.",
|
||||
@@ -1160,6 +1160,20 @@ pub fn connect(
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p` is a file someone may run (Windows: a file; what it can
|
||||
/// run as is told by its extension, which the caller chose).
|
||||
fn is_runnable(p: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::metadata(p).is_ok_and(|m| m.is_file())
|
||||
}
|
||||
}
|
||||
|
||||
/// Let the agent `agent` ("claude" or "codex") reach the books `book`:
|
||||
/// their token kept in its token file (made if none is), and `wanderbooks
|
||||
/// mcp --book <book>` added to the agent's own list as
|
||||
@@ -1176,7 +1190,7 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
|
||||
// The PATH first; then where the agents' installers put them (a
|
||||
// viewer started from the desktop has a short PATH).
|
||||
// ($HOME: a test's own home is never the account's.)
|
||||
let home = std::env::var_os("HOME")
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
|
||||
.map(PathBuf::from)
|
||||
.filter(|h| h.is_absolute());
|
||||
let usual = [
|
||||
@@ -1185,6 +1199,8 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
|
||||
".bun/bin",
|
||||
".cargo/bin",
|
||||
".volta/bin",
|
||||
// Where npm puts global programs on Windows.
|
||||
"AppData/Roaming/npm",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|d| home.as_ref().map(|h| h.join(d)))
|
||||
@@ -1199,11 +1215,19 @@ pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<Str
|
||||
.into_iter()
|
||||
.chain(usual)
|
||||
.filter(|d| d.is_absolute())
|
||||
.map(|d| d.join(cli))
|
||||
.find(|p| {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
.flat_map(|d| {
|
||||
// Windows runs a program by its extension.
|
||||
let names: &[&str] = if cfg!(windows) {
|
||||
&[".exe", ".cmd", ".bat"]
|
||||
} else {
|
||||
&[""]
|
||||
};
|
||||
names
|
||||
.iter()
|
||||
.map(|ext| d.join(format!("{cli}{ext}")))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.find(|p| is_runnable(p))
|
||||
.ok_or_else(|| {
|
||||
format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)")
|
||||
})?;
|
||||
@@ -1610,8 +1634,7 @@ fn vault_beside(source: &Path) -> Option<PathBuf> {
|
||||
|
||||
/// Whether standard input is a terminal.
|
||||
fn at_a_terminal() -> bool {
|
||||
// SAFETY: isatty has no preconditions.
|
||||
unsafe { libc::isatty(0) == 1 }
|
||||
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||
}
|
||||
|
||||
/// `wanderbooks books adopt` (INVARIANT 3).
|
||||
@@ -2397,10 +2420,9 @@ fn on_the_socket(
|
||||
)
|
||||
})?;
|
||||
let token = zeroize::Zeroizing::new(token);
|
||||
let socket = ledger_store::root::SocketPath::new(&run_dir, ledger_mcp::local::MCP_SOCKET)
|
||||
let socket = ledger_mcp::ipc::Endpoint::new(&run_dir, ledger_mcp::local::MCP_SOCKET)
|
||||
.map_err(|e| format!("can't reach the books server: {e}"))?;
|
||||
let http = reqwest::Client::builder()
|
||||
.unix_socket(socket.path())
|
||||
let http = crate::channel::http_builder(&socket)
|
||||
.no_proxy()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
+129
-62
@@ -53,6 +53,7 @@ use crate::knobs::{FLAG, Knobs};
|
||||
use crate::lease::{self, Ask, HostMsg, Intent, LeaseInfo, Reason};
|
||||
use ledger_store::root::Root;
|
||||
use std::collections::BTreeMap;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
@@ -80,7 +81,23 @@ const LOG_LIMIT: u64 = 8 * 1024 * 1024;
|
||||
/// The exit code when another host holds the root.
|
||||
pub const EXIT_ANOTHER_HOST: i32 = 3;
|
||||
|
||||
/// File options for what the host makes: mode 0600 on Unix; on Windows
|
||||
/// the file takes the owner-only DACL its folder carries (P11c).
|
||||
fn private_options() -> std::fs::OpenOptions {
|
||||
#[allow(unused_mut)]
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
#[cfg(unix)]
|
||||
options.mode(0o600);
|
||||
options
|
||||
}
|
||||
|
||||
/// Windows passes no descriptor to a child unless asked to, so there is
|
||||
/// nothing to close (INVARIANT 2).
|
||||
#[cfg(not(unix))]
|
||||
fn close_stray_descriptors() {}
|
||||
|
||||
/// Close every descriptor above 2 (INVARIANT 2).
|
||||
#[cfg(unix)]
|
||||
fn close_stray_descriptors() {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -112,12 +129,7 @@ fn confined(run: &Path) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
let probe = run.join(format!(".host-probe-{}", std::process::id()));
|
||||
match std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&probe)
|
||||
{
|
||||
match private_options().write(true).create_new(true).open(&probe) {
|
||||
Ok(_) => {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
None
|
||||
@@ -138,11 +150,10 @@ fn take_host_lock(run: &Path) -> Result<Option<std::fs::File>, String> {
|
||||
/// [`take_host_lock`], waiting at most `wait` (tests shorten it).
|
||||
fn take_host_lock_within(run: &Path, wait: Duration) -> Result<Option<std::fs::File>, String> {
|
||||
let path = run.join(lease::HOST_LOCK);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
let file = private_options()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("can't open the host lock: {}", e.kind()))?;
|
||||
let deadline = std::time::Instant::now() + wait;
|
||||
@@ -180,11 +191,7 @@ fn open_log_within(path: &Path, limit: u64) -> std::io::Result<std::fs::File> {
|
||||
if std::fs::metadata(path).is_ok_and(|m| m.len() > limit) {
|
||||
let _ = std::fs::rename(path, path.with_extension("log.1"));
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
private_options().create(true).append(true).open(path)
|
||||
}
|
||||
|
||||
/// The executable the server is started from (INVARIANT 3).
|
||||
@@ -196,9 +203,13 @@ fn children_executable() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// A program file's identity: what changes when the file is replaced.
|
||||
type Identity = (u64, u64);
|
||||
|
||||
/// The running executable's file identity, and its file's now: they
|
||||
/// differ once the file was replaced (INVARIANT 7).
|
||||
fn running_identity() -> Option<(u64, u64)> {
|
||||
#[cfg(unix)]
|
||||
fn running_identity() -> Option<Identity> {
|
||||
let running = if cfg!(target_os = "linux") {
|
||||
std::fs::metadata("/proc/self/exe").ok()?
|
||||
} else {
|
||||
@@ -207,19 +218,46 @@ fn running_identity() -> Option<(u64, u64)> {
|
||||
Some((running.dev(), running.ino()))
|
||||
}
|
||||
|
||||
/// Windows can't replace a program that runs, so an update puts the new
|
||||
/// file at the old path (the old one moved aside): the identity is the
|
||||
/// file's size and last write at the path, read when the host started.
|
||||
#[cfg(windows)]
|
||||
fn running_identity() -> Option<Identity> {
|
||||
identity_of(&std::env::current_exe().ok()?)
|
||||
}
|
||||
|
||||
/// The identity of the file at this executable's path, if it is a
|
||||
/// program (a file half-written or gone doesn't count yet).
|
||||
fn file_identity() -> Option<(u64, u64)> {
|
||||
fn file_identity() -> Option<Identity> {
|
||||
identity_of(&crate::start::this_executable().ok()?)
|
||||
}
|
||||
|
||||
/// The identity (device, inode) of the program at `path`: a file someone
|
||||
/// may run; else None.
|
||||
fn identity_of(path: &Path) -> Option<(u64, u64)> {
|
||||
#[cfg(unix)]
|
||||
fn identity_of(path: &Path) -> Option<Identity> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| (meta.dev(), meta.ino()))
|
||||
}
|
||||
|
||||
/// The identity (size, last write in nanoseconds) of the program at
|
||||
/// `path`: a file; else None.
|
||||
#[cfg(windows)]
|
||||
fn identity_of(path: &Path) -> Option<Identity> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let written = meta
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?;
|
||||
meta.is_file().then(|| {
|
||||
(
|
||||
meta.len(),
|
||||
u64::try_from(written.as_nanos()).unwrap_or(u64::MAX),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait at most `wait` for a host to answer on the lease socket: whether
|
||||
/// one did.
|
||||
fn wait_for_a_host(run: &Path, wait: Duration) -> bool {
|
||||
@@ -347,19 +385,29 @@ pub fn run(
|
||||
/// Start the next host from the file now on disk, with this one's
|
||||
/// arguments and (already clean) environment (INVARIANT 7).
|
||||
fn restart() -> Result<(), String> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let exe = crate::start::this_executable()?;
|
||||
let mut c = std::process::Command::new(exe);
|
||||
c.args(std::env::args_os().skip(1))
|
||||
.current_dir("/")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null());
|
||||
// SAFETY: setsid is async-signal-safe and touches only the child.
|
||||
unsafe {
|
||||
c.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
c.current_dir("/");
|
||||
// SAFETY: setsid is async-signal-safe and touches only the child.
|
||||
unsafe {
|
||||
c.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// Detached from this console and its Ctrl+C, as a new session is
|
||||
// on Unix.
|
||||
c.creation_flags(crate::start::DETACHED_HOST);
|
||||
}
|
||||
c.spawn()
|
||||
.map(drop)
|
||||
@@ -456,20 +504,11 @@ impl Host {
|
||||
/// Bind the lease socket, start the server, and serve leases until
|
||||
/// the books close.
|
||||
async fn serve(mut self) -> Result<Ending, String> {
|
||||
let listener = bind(&self.run)?;
|
||||
let mut listener = bind(&self.run)?;
|
||||
let (events, mut inbox) = mpsc::unbounded_channel::<Event>();
|
||||
let connections = TaskTracker::new();
|
||||
let mut signals = Vec::new();
|
||||
for kind in [
|
||||
tokio::signal::unix::SignalKind::terminate(),
|
||||
tokio::signal::unix::SignalKind::interrupt(),
|
||||
tokio::signal::unix::SignalKind::hangup(),
|
||||
] {
|
||||
signals.push(
|
||||
tokio::signal::unix::signal(kind)
|
||||
.map_err(|e| format!("can't watch for signals: {e}"))?,
|
||||
);
|
||||
}
|
||||
let signals =
|
||||
ledger_mcp::ipc::stop_signals().map_err(|e| format!("can't watch for signals: {e}"))?;
|
||||
let (stop_tx, mut stop) = mpsc::unbounded_channel::<()>();
|
||||
for mut s in signals {
|
||||
let stop_tx = stop_tx.clone();
|
||||
@@ -513,9 +552,9 @@ impl Host {
|
||||
}
|
||||
}
|
||||
accepted = listener.accept() => match accepted {
|
||||
Ok((stream, _)) if ledger_mcp::local::peer_is_us(&stream) => {
|
||||
Ok(stream) if ledger_mcp::ipc::peer_is_us(&stream) => {
|
||||
// The holder's process, as the kernel knows it.
|
||||
let pid = ledger_mcp::local::peer_pid(&stream);
|
||||
let pid = ledger_mcp::ipc::peer_pid(&stream);
|
||||
connections.spawn(connection(stream, pid, events.clone()));
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -674,7 +713,9 @@ impl Host {
|
||||
let log = open_log(&self.run.join(SERVER_LOG))
|
||||
.map_err(|e| format!("can't open the server's log: {}", e.kind()))?;
|
||||
let mut command = tokio::process::Command::new(children_executable());
|
||||
command.arg0("wanderbooks").arg("server");
|
||||
#[cfg(unix)]
|
||||
command.arg0("wanderbooks");
|
||||
command.arg("server");
|
||||
if self.explicit_root {
|
||||
command.arg("--root").arg(self.root.path());
|
||||
}
|
||||
@@ -815,11 +856,10 @@ fn consented(socket: &Path, book: String, passphrase: ledger_mcp::admin::Passphr
|
||||
|
||||
/// Take run/update.lock (exclusive), if it is free now.
|
||||
fn take_update_lock(run: &Path) -> Result<std::fs::File, String> {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
let file = private_options()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(run.join(lease::UPDATE_LOCK))
|
||||
.map_err(|e| format!("can't open the update lock: {}", e.kind()))?;
|
||||
match file.try_lock() {
|
||||
@@ -834,31 +874,22 @@ fn take_update_lock(run: &Path) -> Result<std::fs::File, String> {
|
||||
}
|
||||
|
||||
/// Bind run/lease.sock, replacing a stale one (only the host holding the
|
||||
/// lock gets here).
|
||||
fn bind(run: &Path) -> Result<tokio::net::UnixListener, String> {
|
||||
let path = run.join(lease::LEASE_SOCKET);
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("can't replace the lease socket: {}", e.kind())),
|
||||
}
|
||||
let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET)
|
||||
/// lock gets here); on Windows the lease pipe of the root.
|
||||
fn bind(run: &Path) -> Result<ledger_mcp::ipc::Listener, String> {
|
||||
let endpoint = ledger_mcp::ipc::Endpoint::new(run, lease::LEASE_SOCKET)
|
||||
.map_err(|e| format!("can't make the lease socket: {e}"))?;
|
||||
let listener = tokio::net::UnixListener::bind(short.path())
|
||||
.map_err(|e| format!("can't make the lease socket: {}", e.kind()))?;
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| format!("can't make the lease socket private: {}", e.kind()))?;
|
||||
Ok(listener)
|
||||
ledger_mcp::ipc::Listener::bind(&endpoint)
|
||||
.map_err(|e| format!("can't make the lease socket: {}", e.kind()))
|
||||
}
|
||||
|
||||
/// One connection: its first line, then a lease or one answer
|
||||
/// (lease.rs INVARIANT 1).
|
||||
async fn connection(
|
||||
stream: tokio::net::UnixStream,
|
||||
stream: ledger_mcp::ipc::Accepted,
|
||||
peer_pid: Option<u32>,
|
||||
events: mpsc::UnboundedSender<Event>,
|
||||
) {
|
||||
let (read, mut write) = stream.into_split();
|
||||
let (read, mut write) = ledger_mcp::ipc::split_accepted(stream);
|
||||
let mut reader = tokio::io::BufReader::new(read.take(lease::MAX_LINE as u64 + 1));
|
||||
let mut first = String::new();
|
||||
let answer = |msg: HostMsg| lease::line(&msg);
|
||||
@@ -1019,13 +1050,31 @@ mod tests {
|
||||
"123456"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&log).unwrap(), "", "a new log");
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&log).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&log).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_folder_the_host_can_write_isnt_a_sandbox() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(confined(dir.path()), None);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(dir.path()).unwrap().count(),
|
||||
0,
|
||||
"its probe went"
|
||||
);
|
||||
// A folder that isn't there: no sign of a sandbox.
|
||||
assert_eq!(confined(&dir.path().join("gone")), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_folder_the_host_can_write_isnt_a_sandbox() {
|
||||
let seccomp = std::fs::read_to_string("/proc/self/status")
|
||||
@@ -1061,6 +1110,24 @@ mod tests {
|
||||
assert_eq!(confined(&dir.path().join("gone")), None);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn only_a_file_has_an_identity_and_a_replaced_one_another() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("wanderbooks.exe");
|
||||
std::fs::write(&file, "x").unwrap();
|
||||
let first = identity_of(&file).expect("a file");
|
||||
// Replaced by another file (as an update does): another identity.
|
||||
let next = dir.path().join("next.exe");
|
||||
std::fs::write(&next, "yy").unwrap();
|
||||
std::fs::rename(&next, &file).unwrap();
|
||||
let second = identity_of(&file).unwrap();
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(identity_of(dir.path()), None, "a folder isn't one");
|
||||
assert_eq!(identity_of(&dir.path().join("gone")), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_a_program_file_has_an_identity() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
@@ -11,17 +11,13 @@
|
||||
//! viewer's Books menu does). Agents' tokens are kept in files under the data root
|
||||
//! ([`tokens`]).
|
||||
|
||||
#[cfg(unix)]
|
||||
pub mod bridge;
|
||||
#[cfg(unix)]
|
||||
pub mod channel;
|
||||
pub mod commands;
|
||||
#[cfg(unix)]
|
||||
pub mod host;
|
||||
pub mod knobs;
|
||||
pub mod lease;
|
||||
#[cfg(unix)]
|
||||
pub mod opener;
|
||||
#[cfg(unix)]
|
||||
mod start;
|
||||
pub mod tokens;
|
||||
|
||||
|
||||
@@ -109,8 +109,8 @@ pub struct Opener {
|
||||
pub struct Lease {
|
||||
/// Its number.
|
||||
pub id: u64,
|
||||
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>,
|
||||
_write: tokio::net::unix::OwnedWriteHalf,
|
||||
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<ledger_mcp::ipc::ConnectedRead>>>,
|
||||
_write: ledger_mcp::ipc::ConnectedWrite,
|
||||
}
|
||||
|
||||
impl Lease {
|
||||
@@ -207,11 +207,11 @@ impl Opener {
|
||||
let mut started: Option<Instant> = None;
|
||||
loop {
|
||||
wait_for_restart(&self.run).await;
|
||||
let short = ledger_store::root::SocketPath::new(&self.run, lease::LEASE_SOCKET)
|
||||
let endpoint = ledger_mcp::ipc::Endpoint::new(&self.run, lease::LEASE_SOCKET)
|
||||
.map_err(|e| format!("can't reach the books' host: {e}"))?;
|
||||
match tokio::net::UnixStream::connect(short.path()).await {
|
||||
match ledger_mcp::ipc::connect(&endpoint).await {
|
||||
Ok(stream) => {
|
||||
if !ledger_mcp::local::peer_is_us(&stream) {
|
||||
if !ledger_mcp::ipc::peer_is_us(&stream) {
|
||||
return Err(
|
||||
"the books' host socket belongs to another account: not used".into(),
|
||||
);
|
||||
@@ -279,8 +279,8 @@ impl Opener {
|
||||
}
|
||||
|
||||
/// Say hello on a connection and read the host's first word.
|
||||
async fn ask(&self, stream: tokio::net::UnixStream) -> Result<Asked, String> {
|
||||
let (read, mut write) = stream.into_split();
|
||||
async fn ask(&self, stream: ledger_mcp::ipc::Connected) -> Result<Asked, String> {
|
||||
let (read, mut write) = ledger_mcp::ipc::split_connected(stream);
|
||||
let hello = Hello {
|
||||
proto: lease::PROTO,
|
||||
ask: Ask::Hold {
|
||||
@@ -402,14 +402,14 @@ pub fn ask_once(run: &Path, ask: Ask) -> Result<Option<HostMsg>, String> {
|
||||
/// [`ask_once`], waiting at most `wait` for the answer (a probe).
|
||||
pub fn ask_once_within(run: &Path, ask: Ask, wait: Duration) -> Result<Option<HostMsg>, String> {
|
||||
use std::io::{BufRead, Write};
|
||||
let short = ledger_store::root::SocketPath::new(run, lease::LEASE_SOCKET)
|
||||
let endpoint = ledger_mcp::ipc::Endpoint::new(run, lease::LEASE_SOCKET)
|
||||
.map_err(|e| format!("can't reach the books' host: {e}"))?;
|
||||
let stream = match std::os::unix::net::UnixStream::connect(short.path()) {
|
||||
let stream = match ledger_mcp::ipc::connect_std(&endpoint) {
|
||||
Ok(s) => s,
|
||||
Err(e) if no_host(&e) => return Ok(None),
|
||||
Err(e) => return Err(format!("can't reach the books' host: {}", e.kind())),
|
||||
};
|
||||
if !ledger_mcp::local::peer_is_us(&stream) {
|
||||
if !ledger_mcp::ipc::peer_is_us(&stream) {
|
||||
return Err("the books' host socket belongs to another account: not used".into());
|
||||
}
|
||||
stream
|
||||
|
||||
@@ -28,18 +28,34 @@
|
||||
//! ```
|
||||
|
||||
use crate::knobs::Knobs;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
#[cfg(unix)]
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The host's PATH (INVARIANT 1).
|
||||
#[cfg(unix)]
|
||||
const PATH: &str = "/usr/local/bin:/usr/bin:/bin";
|
||||
|
||||
/// Where systemd-run may be (never looked up on the opener's PATH).
|
||||
#[cfg(unix)]
|
||||
const SYSTEMD_RUN: [&str; 2] = ["/usr/bin/systemd-run", "/bin/systemd-run"];
|
||||
|
||||
/// Windows: a process of its own, without this console, its Ctrl+C or its
|
||||
/// window (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP): what `setsid`
|
||||
/// is on Unix.
|
||||
#[cfg(windows)]
|
||||
pub(crate) const DETACHED_HOST: u32 = 0x0000_0008 | 0x0000_0200;
|
||||
/// Windows: leave the job the opener runs in, if it lets its processes
|
||||
/// (CREATE_BREAKAWAY_FROM_JOB): a host must not end with an agent's job,
|
||||
/// as it must not end with an app's scope on Linux (INVARIANT 3).
|
||||
#[cfg(windows)]
|
||||
const BREAKAWAY: u32 = 0x0100_0000;
|
||||
|
||||
/// The host's log, in the root's `run/`.
|
||||
pub const HOST_LOG: &str = "host.log";
|
||||
/// A log larger than this is moved aside when a host starts.
|
||||
@@ -60,11 +76,13 @@ pub fn this_executable() -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
/// This account's user ID.
|
||||
#[cfg(unix)]
|
||||
fn own_uid() -> u32 {
|
||||
// SAFETY: geteuid has no preconditions and can't fail.
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private
|
||||
/// runtime folder: absolute, a real folder (no link), owned by this
|
||||
/// account, and closed to everyone else.
|
||||
@@ -79,6 +97,7 @@ pub fn own_runtime_dir(value: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
|
||||
(meta.is_dir() && meta.uid() == own_uid() && meta.mode() & 0o077 == 0).then_some(dir)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// `DBUS_SESSION_BUS_ADDRESS` from `value`, if it is the bus in `runtime`
|
||||
/// (`unix:path=<runtime>/bus`, perhaps with a guid after a comma).
|
||||
pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String> {
|
||||
@@ -88,6 +107,7 @@ pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String
|
||||
(rest.is_empty() || rest.starts_with(',')).then(|| value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// This account's runtime folder: the one named in the environment if it
|
||||
/// is ours and private; else, when the opener's environment has none (a
|
||||
/// client that scrubs it, as Codex does), the system's for this account,
|
||||
@@ -100,6 +120,7 @@ fn runtime_dir() -> Option<PathBuf> {
|
||||
.or_else(|| own_runtime_dir(Some(format!("/run/user/{}", own_uid()).as_ref())))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// The session bus in `runtime`: the one named in the environment if it
|
||||
/// is there, else its usual socket there, if one exists.
|
||||
fn session_bus(runtime: &Path) -> Option<String> {
|
||||
@@ -115,6 +136,7 @@ fn session_bus(runtime: &Path) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// The ssh agent's socket named in `named`, if it is a socket this
|
||||
/// account owns at an absolute path (INVARIANT 1).
|
||||
fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
|
||||
@@ -126,6 +148,7 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
|
||||
.then(|| path.as_os_str().to_os_string())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// The host's environment (INVARIANT 1), given the opener's
|
||||
/// `SSH_AUTH_SOCK`.
|
||||
fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, std::ffi::OsString)> {
|
||||
@@ -145,6 +168,7 @@ fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, st
|
||||
env
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// Whether this process sits in a unit under this account's user manager
|
||||
/// (an app's scope or service, as uwsm makes for every app), whose stop
|
||||
/// would take the host with it; going by the unified cgroup line of
|
||||
@@ -160,6 +184,7 @@ pub fn in_app_scope(cgroup: &str, uid: u32) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// systemd-run, if this process is in an app scope and the user manager
|
||||
/// can be reached (INVARIANT 3).
|
||||
fn systemd_run() -> Option<&'static str> {
|
||||
@@ -173,6 +198,7 @@ fn systemd_run() -> Option<&'static str> {
|
||||
SYSTEMD_RUN.into_iter().find(|p| Path::new(p).is_file())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// Start a host for the root (`root`, else the one the host finds
|
||||
/// itself), with these knobs and these server flags (INVARIANTS 1-5).
|
||||
pub fn start_host(
|
||||
@@ -266,9 +292,11 @@ pub fn start_host(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
/// How long systemd-run has to make its scope and run the host.
|
||||
const SCOPE_WAIT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[cfg(unix)]
|
||||
/// Whether systemd-run made its scope and ran the host (INVARIANT 3): the
|
||||
/// host's first line is in its log after `from`, or systemd-run is still
|
||||
/// running when SCOPE_WAIT is up (it became the host). Only systemd-run
|
||||
@@ -298,6 +326,111 @@ fn scope_made(child: &mut std::process::Child, log: &std::fs::File, from: u64) -
|
||||
}
|
||||
}
|
||||
|
||||
/// The host's environment on Windows (INVARIANT 1's counterpart): the
|
||||
/// system's own variables, the account's profile, and a PATH fixed to the
|
||||
/// system folders and Git for Windows (the backups run `git`). Nothing
|
||||
/// else of the opener's passes. (CHOICE, P11c: no authority states a
|
||||
/// Windows list; this is the least a Windows program and `git` need.)
|
||||
#[cfg(windows)]
|
||||
fn host_env(home: &Path) -> Vec<(String, std::ffi::OsString)> {
|
||||
let var = |name: &str| std::env::var_os(name);
|
||||
let system_root: PathBuf = var("SystemRoot")
|
||||
.or_else(|| var("windir"))
|
||||
.map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
|
||||
let program_files: PathBuf =
|
||||
var("ProgramFiles").map_or_else(|| PathBuf::from(r"C:\Program Files"), PathBuf::from);
|
||||
let path = std::env::join_paths([
|
||||
system_root.join("System32"),
|
||||
system_root.clone(),
|
||||
program_files.join("Git").join("cmd"),
|
||||
])
|
||||
.unwrap_or_default();
|
||||
let mut env: Vec<(String, std::ffi::OsString)> = vec![
|
||||
("PATH".into(), path),
|
||||
("SystemRoot".into(), system_root.clone().into_os_string()),
|
||||
("windir".into(), system_root.into_os_string()),
|
||||
("USERPROFILE".into(), home.as_os_str().to_os_string()),
|
||||
("HOME".into(), home.as_os_str().to_os_string()),
|
||||
("TEMP".into(), std::env::temp_dir().into_os_string()),
|
||||
("TMP".into(), std::env::temp_dir().into_os_string()),
|
||||
];
|
||||
if let Some(local) = ledger_store::winsec::local_app_data() {
|
||||
env.push(("LOCALAPPDATA".into(), local.into_os_string()));
|
||||
}
|
||||
for name in ["APPDATA", "ComSpec", "PATHEXT", "ProgramFiles"] {
|
||||
if let Some(value) = var(name) {
|
||||
env.push((name.into(), value));
|
||||
}
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
/// Start a host for the root (`root`, else the one the host finds
|
||||
/// itself), with these knobs and these server flags (INVARIANTS 1, 2, 4
|
||||
/// and 5; there is no scope to leave, but a job to break away from).
|
||||
#[cfg(windows)]
|
||||
pub fn start_host(
|
||||
root: Option<&Path>,
|
||||
knobs: &Knobs,
|
||||
server_flags: &[String],
|
||||
) -> Result<(), String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
|
||||
let run = found.run_dir().map_err(|e| e.to_string())?;
|
||||
let home = ledger_store::root::account_home().map_err(|e| e.to_string())?;
|
||||
let exe = this_executable()?;
|
||||
let mut args: Vec<std::ffi::OsString> = vec!["host".into()];
|
||||
if root.is_some() {
|
||||
args.push("--root".into());
|
||||
args.push(found.path().as_os_str().to_os_string());
|
||||
}
|
||||
args.extend(knobs.args().into_iter().map(Into::into));
|
||||
args.extend(server_flags.iter().map(Into::into));
|
||||
let log = || {
|
||||
let path = run.join(HOST_LOG);
|
||||
if std::fs::metadata(&path).is_ok_and(|m| m.len() > LOG_LIMIT)
|
||||
&& !crate::opener::host_lock_held(&run)
|
||||
{
|
||||
let _ = std::fs::rename(&path, path.with_extension("log.1"));
|
||||
}
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|e| format!("can't open the host's log: {}", e.kind()))
|
||||
};
|
||||
let spawn = |flags: u32| -> Result<std::process::Child, String> {
|
||||
Command::new(&exe)
|
||||
.args(&args)
|
||||
.env_clear()
|
||||
.envs(host_env(&home))
|
||||
.current_dir(&run)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(log()?)
|
||||
.creation_flags(flags)
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
// Named for the caller below: breakaway refused.
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
"denied".to_string()
|
||||
} else {
|
||||
format!("can't start the books' host: {e}")
|
||||
}
|
||||
})
|
||||
};
|
||||
let child = match spawn(DETACHED_HOST | BREAKAWAY) {
|
||||
Ok(child) => child,
|
||||
// The job the opener runs in doesn't let a process leave it: the
|
||||
// host starts inside it, as a host without a scope does on Linux.
|
||||
Err(why) if why == "denied" => spawn(DETACHED_HOST)?,
|
||||
Err(why) => return Err(why),
|
||||
};
|
||||
reap(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reap a started host when it ends, without waiting for it (INVARIANT 5).
|
||||
fn reap(mut child: std::process::Child) {
|
||||
let _ = std::thread::Builder::new()
|
||||
@@ -307,7 +440,7 @@ fn reap(mut child: std::process::Child) {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -457,3 +590,44 @@ mod tests {
|
||||
assert!(exe.is_file(), "{exe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod windows_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn this_executable_is_a_file_on_disk() {
|
||||
let exe = this_executable().unwrap();
|
||||
assert!(exe.is_file(), "{exe:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_environment_carries_nothing_of_the_openers_choosing() {
|
||||
let env = host_env(Path::new(r"C:\Users\someone"));
|
||||
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
|
||||
for n in &names {
|
||||
assert!(
|
||||
[
|
||||
"PATH",
|
||||
"SystemRoot",
|
||||
"windir",
|
||||
"USERPROFILE",
|
||||
"HOME",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"LOCALAPPDATA",
|
||||
"APPDATA",
|
||||
"ComSpec",
|
||||
"PATHEXT",
|
||||
"ProgramFiles"
|
||||
]
|
||||
.contains(n),
|
||||
"{n}"
|
||||
);
|
||||
}
|
||||
let home = env.iter().find(|(n, _)| n == "HOME").unwrap();
|
||||
assert_eq!(home.1, r"C:\Users\someone");
|
||||
let path = env.iter().find(|(n, _)| n == "PATH").unwrap();
|
||||
assert!(path.1.to_string_lossy().to_lowercase().contains("system32"));
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,9 @@ impl Keeper {
|
||||
std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700))
|
||||
.map_err(refused)?;
|
||||
}
|
||||
// Windows: this account's alone, and what is made in it too.
|
||||
#[cfg(windows)]
|
||||
ledger_store::winsec::set_owner_only(&self.0).map_err(refused)?;
|
||||
let name = account.file_name();
|
||||
let tmp = self.0.join(format!("{name}.tmp{}", std::process::id()));
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
|
||||
Reference in new issue
Block a user