merge: P11c, the native Windows port (branch P11c, 36a7ad51; D-62): the portable Windows job is green
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
05aece9fd1
45 files changed
+2487
-506
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
# Tests compare text and checked-in hashes byte for byte: every OS checks out LF (the
|
||||||
|
# Windows job of check.yml would otherwise get CRLF from autocrlf).
|
||||||
|
* text=auto eol=lf
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
experimental: true
|
experimental: false
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
continue-on-error: ${{ matrix.experimental }}
|
continue-on-error: ${{ matrix.experimental }}
|
||||||
# Same disk discipline as above: no incremental caches, line tables only.
|
# Same disk discipline as above: no incremental caches, line tables only.
|
||||||
|
|||||||
Generated
+1
@@ -4144,6 +4144,7 @@ dependencies = [
|
|||||||
"subtle",
|
"subtle",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ Sheet is frozen as of 2026-10-11. The remaining work, its order and its
|
|||||||
hand-off are `plan/served.md` section 8.0; the destination is one
|
hand-off are `plan/served.md` section 8.0; the destination is one
|
||||||
installed app for Mac, Windows and Linux (P11), then the hosted service
|
installed app for Mac, Windows and Linux (P11), then the hosted service
|
||||||
the plan describes and does not build. Today the agent path runs on
|
the plan describes and does not build. Today the agent path runs on
|
||||||
Linux and macOS: tokens live in a 0600 file under the private data root
|
Linux, macOS and Windows from a source build: tokens live in a file under
|
||||||
(D-63); the rest of the host is Unix-only, so Windows is WSL2 only until
|
the private data root (D-63), the data root is `%LOCALAPPDATA%\wanderbooks`
|
||||||
its sockets and data root are ported.
|
with an owner-only access list on Windows (P11c, D-62), and the books'
|
||||||
|
local channels are named pipes there. Windows is not yet packaged: no
|
||||||
|
installer, no signing (P11a).
|
||||||
|
|
||||||
See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
|
See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
|
||||||
[`plan/roadmap.md`](plan/roadmap.md) for where it's going.
|
[`plan/roadmap.md`](plan/roadmap.md) for where it's going.
|
||||||
@@ -88,7 +90,7 @@ them; nothing needs sudo):
|
|||||||
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme
|
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme
|
||||||
```
|
```
|
||||||
|
|
||||||
(Linux and macOS. On Windows run it inside WSL2.)
|
(Linux and macOS. On Windows build from source: `cargo install --path crates/ledger-cli`, then the three commands below.)
|
||||||
|
|
||||||
Already installed, three commands: `wanderbooks setup`, `wanderbooks books
|
Already installed, three commands: `wanderbooks setup`, `wanderbooks books
|
||||||
create acme`, `wanderbooks connect claude --book acme` (or `connect codex`).
|
create acme`, `wanderbooks connect claude --book acme` (or `connect codex`).
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ fn gui_args(args: Vec<String>) -> Vec<String> {
|
|||||||
|
|
||||||
/// The viewer's owner (ledger-gui owner.rs): its Books menu, done as the
|
/// The viewer's owner (ledger-gui owner.rs): its Books menu, done as the
|
||||||
/// command line does it (the books server, the agents' own commands).
|
/// command line does it (the books server, the agents' own commands).
|
||||||
#[cfg(all(feature = "gui", unix))]
|
#[cfg(feature = "gui")]
|
||||||
struct ViewerOwner(ledger_host::commands::Common);
|
struct ViewerOwner(ledger_host::commands::Common);
|
||||||
|
|
||||||
#[cfg(all(feature = "gui", unix))]
|
#[cfg(feature = "gui")]
|
||||||
impl ledger_gui::owner::Owner for ViewerOwner {
|
impl ledger_gui::owner::Owner for ViewerOwner {
|
||||||
fn close_books(&self) -> Result<String, String> {
|
fn close_books(&self) -> Result<String, String> {
|
||||||
ledger_host::commands::close_books(&self.0)
|
ledger_host::commands::close_books(&self.0)
|
||||||
@@ -179,7 +179,6 @@ impl ledger_gui::owner::Owner for ViewerOwner {
|
|||||||
#[cfg(feature = "gui")]
|
#[cfg(feature = "gui")]
|
||||||
fn run_gui(args: Vec<String>) -> i32 {
|
fn run_gui(args: Vec<String>) -> i32 {
|
||||||
let args = gui_args(args);
|
let args = gui_args(args);
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
{
|
||||||
let value = |flag: &str| {
|
let value = |flag: &str| {
|
||||||
// The last given, as the viewer reads it.
|
// The last given, as the viewer reads it.
|
||||||
@@ -315,7 +314,6 @@ fn run_setup(args: Vec<String>) -> i32 {
|
|||||||
|
|
||||||
/// After setup: the owner passphrase, asked for at a terminal if none is
|
/// After setup: the owner passphrase, asked for at a terminal if none is
|
||||||
/// set yet; elsewhere (a script, an agent) only said how to set it.
|
/// set yet; elsewhere (a script, an agent) only said how to set it.
|
||||||
#[cfg(unix)]
|
|
||||||
fn owner_passphrase(root: &ledger_store::root::Root) -> i32 {
|
fn owner_passphrase(root: &ledger_store::root::Root) -> i32 {
|
||||||
let auth = match root.auth_dir() {
|
let auth = match root.auth_dir() {
|
||||||
Ok(dir) => dir,
|
Ok(dir) => dir,
|
||||||
@@ -353,14 +351,8 @@ fn owner_passphrase(root: &ledger_store::root::Root) -> i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn owner_passphrase(_root: &ledger_store::root::Root) -> i32 {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `wanderbooks server`: the books server for the data root, on its
|
/// `wanderbooks server`: the books server for the data root, on its
|
||||||
/// local sockets (plan 3.9). The host starts it.
|
/// local sockets (plan 3.9). The host starts it.
|
||||||
#[cfg(unix)]
|
|
||||||
fn run_server(args: Vec<String>) -> i32 {
|
fn run_server(args: Vec<String>) -> i32 {
|
||||||
match ledger_mcp::local::run("wanderbooks server", args) {
|
match ledger_mcp::local::run("wanderbooks server", args) {
|
||||||
Ok(()) => 0,
|
Ok(()) => 0,
|
||||||
@@ -376,7 +368,6 @@ fn run_server(args: Vec<String>) -> i32 {
|
|||||||
|
|
||||||
/// The modes that open and hold the books (plan 3.9): the host, the
|
/// The modes that open and hold the books (plan 3.9): the host, the
|
||||||
/// agents' bridge, and the owner's commands.
|
/// agents' bridge, and the owner's commands.
|
||||||
#[cfg(unix)]
|
|
||||||
fn run_local(mode: &str, args: Vec<String>) -> i32 {
|
fn run_local(mode: &str, args: Vec<String>) -> i32 {
|
||||||
let prog = format!("wanderbooks {mode}");
|
let prog = format!("wanderbooks {mode}");
|
||||||
let result = match mode {
|
let result = match mode {
|
||||||
@@ -388,10 +379,19 @@ fn run_local(mode: &str, args: Vec<String>) -> i32 {
|
|||||||
"token" => ledger_host::commands::token(&prog, args),
|
"token" => ledger_host::commands::token(&prog, args),
|
||||||
"connect" => ledger_host::commands::connect(&prog, args),
|
"connect" => ledger_host::commands::connect(&prog, args),
|
||||||
"passphrase" => ledger_host::commands::passphrase(&prog, args),
|
"passphrase" => ledger_host::commands::passphrase(&prog, args),
|
||||||
|
#[cfg(unix)]
|
||||||
"update" => {
|
"update" => {
|
||||||
ledger_host::update::update(&prog, ledger_host::update::program_version(), args)
|
ledger_host::update::update(&prog, ledger_host::update::program_version(), args)
|
||||||
}
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
"rollback" => ledger_host::update::rollback(&prog, args),
|
"rollback" => ledger_host::update::rollback(&prog, args),
|
||||||
|
// Updates replace a running program's file in place, which Windows
|
||||||
|
// forbids: its updater is P11a step 5.
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
"update" | "rollback" => {
|
||||||
|
eprintln!("error: `{mode}` is not built for Windows yet (P11a step 5)");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
_ => ledger_host::commands::books(&prog, args),
|
_ => ledger_host::commands::books(&prog, args),
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
@@ -408,7 +408,6 @@ fn run_local(mode: &str, args: Vec<String>) -> i32 {
|
|||||||
|
|
||||||
/// `upload` and `download`: the agent's file commands, whose words may
|
/// `upload` and `download`: the agent's file commands, whose words may
|
||||||
/// be paths that aren't text.
|
/// be paths that aren't text.
|
||||||
#[cfg(unix)]
|
|
||||||
fn run_files(mode: &str, args: Vec<std::ffi::OsString>) -> i32 {
|
fn run_files(mode: &str, args: Vec<std::ffi::OsString>) -> i32 {
|
||||||
let prog = format!("wanderbooks {mode}");
|
let prog = format!("wanderbooks {mode}");
|
||||||
let result = match mode {
|
let result = match mode {
|
||||||
@@ -427,27 +426,6 @@ fn run_files(mode: &str, args: Vec<std::ffi::OsString>) -> i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The file commands, where local sockets aren't built yet.
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn run_files(_mode: &str, _args: Vec<std::ffi::OsString>) -> i32 {
|
|
||||||
eprintln!("error: opening the books this way comes to this system with P11");
|
|
||||||
2
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The modes that hold the books, where local sockets aren't built yet.
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn run_local(_mode: &str, _args: Vec<String>) -> i32 {
|
|
||||||
eprintln!("error: opening the books this way comes to this system with P11");
|
|
||||||
2
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `wanderbooks server` where local sockets aren't built yet.
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn run_server(_args: Vec<String>) -> i32 {
|
|
||||||
eprintln!("error: the books server's local sockets come to this system with P11");
|
|
||||||
2
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the program was launched with nothing to say and no terminal
|
/// Whether the program was launched with nothing to say and no terminal
|
||||||
/// to say it to: a menu entry, a double-click, an AppImage (whose launcher
|
/// to say it to: a menu entry, a double-click, an AppImage (whose launcher
|
||||||
/// runs the desktop file's command and appends what it was given, so the
|
/// runs the desktop file's command and appends what it was given, so the
|
||||||
@@ -458,6 +436,7 @@ fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
#[cfg(unix)]
|
||||||
ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION"));
|
ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION"));
|
||||||
// A file's path may be any name the system takes: the file commands
|
// A file's path may be any name the system takes: the file commands
|
||||||
// get the words as given. Everything else is text.
|
// get the words as given. Everything else is text.
|
||||||
|
|||||||
@@ -63,14 +63,10 @@ impl Home {
|
|||||||
.prefix(&format!("wb-{tag}-"))
|
.prefix(&format!("wb-{tag}-"))
|
||||||
.tempdir()
|
.tempdir()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
let mut setup = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
|
||||||
.args(["setup", "--home"])
|
setup.args(["setup", "--home"]).arg(dir.path());
|
||||||
.arg(dir.path())
|
clean_env(&mut setup, dir.path());
|
||||||
.env_clear()
|
let out = setup.output().unwrap();
|
||||||
.env("PATH", "/usr/bin:/bin")
|
|
||||||
.env("HOME", dir.path())
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert!(out.status.success(), "{out:?}");
|
assert!(out.status.success(), "{out:?}");
|
||||||
Self {
|
Self {
|
||||||
dir,
|
dir,
|
||||||
@@ -194,14 +190,14 @@ impl Server {
|
|||||||
.append(true)
|
.append(true)
|
||||||
.open(home.dir.path().join("server.log"))
|
.open(home.dir.path().join("server.log"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
let mut command = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
|
||||||
|
command
|
||||||
.arg("server")
|
.arg("server")
|
||||||
.arg("--root")
|
.arg("--root")
|
||||||
.arg(home.root())
|
.arg(home.root())
|
||||||
.current_dir(cwd)
|
.current_dir(cwd);
|
||||||
.env_clear()
|
clean_env(&mut command, home.dir.path());
|
||||||
.env("PATH", "/usr/bin:/bin")
|
let mut child = command
|
||||||
.env("HOME", home.dir.path())
|
|
||||||
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
|
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
|
||||||
.envs(env.iter().map(|(k, v)| (*k, v.as_os_str())))
|
.envs(env.iter().map(|(k, v)| (*k, v.as_os_str())))
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
@@ -258,10 +254,10 @@ pub fn mcp_http_builder(home: &Home) -> reqwest::ClientBuilder {
|
|||||||
/// [`mcp_http_builder`], for the root's `run/` folder `run`.
|
/// [`mcp_http_builder`], for the root's `run/` folder `run`.
|
||||||
pub fn mcp_http_builder_at(run: &Path) -> reqwest::ClientBuilder {
|
pub fn mcp_http_builder_at(run: &Path) -> reqwest::ClientBuilder {
|
||||||
// The short path is good in this process only, while it lives: kept.
|
// The short path is good in this process only, while it lives: kept.
|
||||||
let short = ledger_store::root::SocketPath::new(run, "mcp.sock").unwrap();
|
let endpoint = ledger_mcp::ipc::Endpoint::new(run, "mcp.sock").unwrap();
|
||||||
let path = short.path().to_path_buf();
|
let builder = ledger_host::channel::http_builder(&endpoint);
|
||||||
std::mem::forget(short);
|
std::mem::forget(endpoint);
|
||||||
reqwest::Client::builder().unix_socket(path)
|
builder
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The settings a settings file's text gives, as books.set_setting changes
|
/// The settings a settings file's text gives, as books.set_setting changes
|
||||||
@@ -846,20 +842,71 @@ pub fn book_files(dir: &Path) -> std::collections::BTreeMap<PathBuf, String> {
|
|||||||
/// Copy the tree `from` into `to`, private as the server keeps a book
|
/// Copy the tree `from` into `to`, private as the server keeps a book
|
||||||
/// (folders 0700, files 0600).
|
/// (folders 0700, files 0600).
|
||||||
pub fn copy_private(from: &Path, to: &Path) {
|
pub fn copy_private(from: &Path, to: &Path) {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::create_dir_all(to).unwrap();
|
std::fs::create_dir_all(to).unwrap();
|
||||||
std::fs::set_permissions(to, std::fs::Permissions::from_mode(0o700)).unwrap();
|
private_folder(to);
|
||||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||||
if entry.file_type().unwrap().is_dir() {
|
if entry.file_type().unwrap().is_dir() {
|
||||||
copy_private(&src, &dst);
|
copy_private(&src, &dst);
|
||||||
} else {
|
} else {
|
||||||
std::fs::copy(&src, &dst).unwrap();
|
std::fs::copy(&src, &dst).unwrap();
|
||||||
std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o600)).unwrap();
|
private_file(&dst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make the folder `dir` private as the server keeps one (0700; on
|
||||||
|
/// Windows this account's alone, by an owner-only DACL).
|
||||||
|
pub fn private_folder(dir: &Path) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
ledger_store::winsec::set_owner_only(dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the file `path` private as the server keeps one (0600; on
|
||||||
|
/// Windows this account's alone).
|
||||||
|
pub fn private_file(path: &Path) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
ledger_store::winsec::set_owner_only(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A child process's environment cleared down to what a program needs to
|
||||||
|
/// run and to find `home` as its own: PATH and HOME on Unix; the system
|
||||||
|
/// folder, a PATH of the system's, the profile and the temporary folder on
|
||||||
|
/// Windows (a program started with nothing at all can't load its DLLs).
|
||||||
|
pub fn clean_env(command: &mut Command, home: &Path) {
|
||||||
|
command.env_clear();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
command.env("PATH", "/usr/bin:/bin").env("HOME", home);
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
|
||||||
|
let system32 = Path::new(&root).join("System32");
|
||||||
|
command
|
||||||
|
.env("SystemRoot", &root)
|
||||||
|
.env("windir", &root)
|
||||||
|
.env(
|
||||||
|
"PATH",
|
||||||
|
std::env::join_paths([system32, root.clone().into()]).unwrap(),
|
||||||
|
)
|
||||||
|
.env("USERPROFILE", home)
|
||||||
|
.env("HOME", home)
|
||||||
|
.env("TEMP", std::env::temp_dir())
|
||||||
|
.env("TMP", std::env::temp_dir());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A test's place: a home (whose folder holds the test's own files, its
|
/// A test's place: a home (whose folder holds the test's own files, its
|
||||||
/// settings file among them) and the books [`BOOK`] in its root, served
|
/// settings file among them) and the books [`BOOK`] in its root, served
|
||||||
/// by a server [`Place::start`] starts, and starts again on each next
|
/// by a server [`Place::start`] starts, and starts again on each next
|
||||||
@@ -922,8 +969,7 @@ impl Place {
|
|||||||
let dir = self.books_data();
|
let dir = self.books_data();
|
||||||
let fresh = !dir.join("journal.jsonl").exists();
|
let fresh = !dir.join("journal.jsonl").exists();
|
||||||
if dir.exists() {
|
if dir.exists() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
private_folder(&dir);
|
||||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
||||||
}
|
}
|
||||||
let cwd = self.cwd.borrow().clone();
|
let cwd = self.cwd.borrow().clone();
|
||||||
let cwd = cwd.as_deref().unwrap_or(self.home.dir.path());
|
let cwd = cwd.as_deref().unwrap_or(self.home.dir.path());
|
||||||
|
|||||||
@@ -439,14 +439,12 @@ fn the_backfill_reads_an_old_fixtures_files_once() {
|
|||||||
// host does.
|
// host does.
|
||||||
books.stop();
|
books.stop();
|
||||||
let run = || {
|
let run = || {
|
||||||
std::process::Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
let mut extract = std::process::Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
|
||||||
|
extract
|
||||||
.args(["books", "extract", &books.id, "3", "--root"])
|
.args(["books", "extract", &books.id, "3", "--root"])
|
||||||
.arg(books.home.root())
|
.arg(books.home.root());
|
||||||
.env_clear()
|
common::clean_env(&mut extract, books.home.dir.path());
|
||||||
.env("PATH", "/usr/bin:/bin")
|
extract.output().unwrap()
|
||||||
.env("HOME", books.home.dir.path())
|
|
||||||
.output()
|
|
||||||
.unwrap()
|
|
||||||
};
|
};
|
||||||
let before = books.journal();
|
let before = books.journal();
|
||||||
let out = run();
|
let out = run();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//! (`server`) and the viewer (`gui`) as modes of itself, each with its own
|
//! (`server`) and the viewer (`gui`) as modes of itself, each with its own
|
||||||
//! flags, and the server-only build has no viewer.
|
//! flags, and the server-only build has no viewer.
|
||||||
|
|
||||||
|
#[cfg_attr(not(unix), allow(unused_imports))]
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
#[cfg(all(feature = "gui", target_os = "linux"))]
|
#[cfg(all(feature = "gui", target_os = "linux"))]
|
||||||
@@ -105,13 +106,12 @@ fn a_viewer_that_cannot_open_a_window_exits_1() {
|
|||||||
let server = common::Server::start(&home);
|
let server = common::Server::start(&home);
|
||||||
home.create(common::BOOK, "USD");
|
home.create(common::BOOK, "USD");
|
||||||
assert!(server.close().success());
|
assert!(server.close().success());
|
||||||
let mut child = wanderbooks()
|
let mut gui = wanderbooks();
|
||||||
.args(["gui", "--root"])
|
gui.args(["gui", "--root"])
|
||||||
.arg(home.root())
|
.arg(home.root())
|
||||||
.args(["--book", common::BOOK])
|
.args(["--book", common::BOOK]);
|
||||||
.env_clear()
|
common::clean_env(&mut gui, home.dir.path());
|
||||||
.env("PATH", "/usr/bin:/bin")
|
let mut child = gui
|
||||||
.env("HOME", home.dir.path())
|
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
@@ -147,7 +147,6 @@ fn the_server_only_build_has_no_viewer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The books server needs a data root: with none, it says to run setup.
|
/// The books server needs a data root: with none, it says to run setup.
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_books_server_needs_a_root() {
|
fn the_books_server_needs_a_root() {
|
||||||
// No root yet: the books server says to run setup.
|
// No root yet: the books server says to run setup.
|
||||||
@@ -258,52 +257,3 @@ fn the_file_commands_end_with_the_code_that_says_how() {
|
|||||||
);
|
);
|
||||||
assert!(!root.exists(), "the books were reached for");
|
assert!(!root.exists(), "the books were reached for");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where local sockets aren't built yet (P11, Windows): the modes that
|
|
||||||
/// hold the books say so and end with 2, never a success; setup makes
|
|
||||||
/// the folder and ends with 0, asking for no passphrase (nothing there
|
|
||||||
/// asks for one yet).
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
#[test]
|
|
||||||
fn without_local_sockets_the_modes_that_hold_the_books_say_so() {
|
|
||||||
for mode in [
|
|
||||||
"server",
|
|
||||||
"host",
|
|
||||||
"mcp",
|
|
||||||
"serve",
|
|
||||||
"close",
|
|
||||||
"books",
|
|
||||||
"doctor",
|
|
||||||
"token",
|
|
||||||
"connect",
|
|
||||||
"passphrase",
|
|
||||||
"upload",
|
|
||||||
"download",
|
|
||||||
] {
|
|
||||||
let out = wanderbooks()
|
|
||||||
.arg(mode)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
|
|
||||||
assert!(
|
|
||||||
String::from_utf8_lossy(&out.stderr).contains("to this system with P11"),
|
|
||||||
"{mode}: {out:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let home = tempfile::Builder::new()
|
|
||||||
.prefix("wb-one-nosock-")
|
|
||||||
.tempdir()
|
|
||||||
.unwrap();
|
|
||||||
let out = wanderbooks()
|
|
||||||
.args(["setup", "--home"])
|
|
||||||
.arg(home.path())
|
|
||||||
.env_remove("XDG_DATA_HOME")
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(out.status.code(), Some(0), "{out:?}");
|
|
||||||
let text = String::from_utf8_lossy(&out.stdout);
|
|
||||||
assert!(text.contains("The books' private folder: made"), "{text}");
|
|
||||||
assert!(!text.contains("passphrase"), "{text}");
|
|
||||||
}
|
|
||||||
@@ -521,6 +521,9 @@ fn files_under(dir: &Path) -> Vec<PathBuf> {
|
|||||||
let p = e.path();
|
let p = e.path();
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
out.extend(files_under(&p));
|
out.extend(files_under(&p));
|
||||||
|
} else if p.extension().is_some_and(|x| x == "lock") {
|
||||||
|
// A lock file holds no data, and on Windows a held lock is
|
||||||
|
// mandatory: reading it is refused while the server runs.
|
||||||
} else {
|
} else {
|
||||||
out.push(p);
|
out.push(p);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,9 @@ mod common;
|
|||||||
/// New books `id` written in `home`'s root by the store itself, before a
|
/// New books `id` written in `home`'s root by the store itself, before a
|
||||||
/// server opens them; the store, still open.
|
/// server opens them; the store, still open.
|
||||||
fn written(home: &Home, id: &str) -> ledger_store::Store {
|
fn written(home: &Home, id: &str) -> ledger_store::Store {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let dir = home.book_dir(id);
|
let dir = home.book_dir(id);
|
||||||
let store = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
|
let store = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
|
||||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
common::private_folder(&dir);
|
||||||
store
|
store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,14 +136,14 @@ impl FlaggedServer {
|
|||||||
.append(true)
|
.append(true)
|
||||||
.open(home.dir.path().join("server.log"))
|
.open(home.dir.path().join("server.log"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
let mut server = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
|
||||||
|
server
|
||||||
.arg("server")
|
.arg("server")
|
||||||
.arg("--root")
|
.arg("--root")
|
||||||
.arg(home.root())
|
.arg(home.root())
|
||||||
.args(args)
|
.args(args);
|
||||||
.env_clear()
|
common::clean_env(&mut server, home.dir.path());
|
||||||
.env("PATH", "/usr/bin:/bin")
|
let mut child = server
|
||||||
.env("HOME", home.dir.path())
|
|
||||||
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
|
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
@@ -184,14 +184,14 @@ impl Drop for FlaggedServer {
|
|||||||
/// Run the server with `args` that should be refused before it serves
|
/// Run the server with `args` that should be refused before it serves
|
||||||
/// anything: its status, stdout and stderr.
|
/// anything: its status, stdout and stderr.
|
||||||
fn refused_server(home: &Home, args: &[String]) -> std::process::Output {
|
fn refused_server(home: &Home, args: &[String]) -> std::process::Output {
|
||||||
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
let mut server = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
|
||||||
|
server
|
||||||
.arg("server")
|
.arg("server")
|
||||||
.arg("--root")
|
.arg("--root")
|
||||||
.arg(home.root())
|
.arg(home.root())
|
||||||
.args(args)
|
.args(args);
|
||||||
.env_clear()
|
common::clean_env(&mut server, home.dir.path());
|
||||||
.env("PATH", "/usr/bin:/bin")
|
server
|
||||||
.env("HOME", home.dir.path())
|
|
||||||
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
|
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
|
||||||
.output()
|
.output()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -240,13 +240,13 @@ fn the_tunnel_door_binds_loopback_only() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The same refusal through `serve`, before it starts a host at all.
|
// The same refusal through `serve`, before it starts a host at all.
|
||||||
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
let mut serve = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
|
||||||
|
serve
|
||||||
.args(["serve", "--root"])
|
.args(["serve", "--root"])
|
||||||
.arg(home.root())
|
.arg(home.root())
|
||||||
.args(["--http", "0.0.0.0:0"])
|
.args(["--http", "0.0.0.0:0"]);
|
||||||
.env_clear()
|
common::clean_env(&mut serve, home.dir.path());
|
||||||
.env("PATH", "/usr/bin:/bin")
|
let out = serve
|
||||||
.env("HOME", home.dir.path())
|
|
||||||
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
|
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
|
||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -380,6 +380,10 @@ fn keep(path: &std::path::Path, png: &[u8]) {
|
|||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
|
||||||
}
|
}
|
||||||
|
// Windows: this account's alone (an owner-only DACL, inherited by the
|
||||||
|
// thumbnails written inside).
|
||||||
|
#[cfg(windows)]
|
||||||
|
let _ = ledger_store::winsec::set_owner_only(dir);
|
||||||
// One name per write, across viewers (the pid) and this one's workers.
|
// One name per write, across viewers (the pid) and this one's workers.
|
||||||
static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
static WRITES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
let tmp = dir.join(format!(
|
let tmp = dir.join(format!(
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ use std::process::Command;
|
|||||||
fn viewer(home: &std::path::Path) -> Command {
|
fn viewer(home: &std::path::Path) -> Command {
|
||||||
let mut c = Command::new(env!("CARGO_BIN_EXE_ledger-gui"));
|
let mut c = Command::new(env!("CARGO_BIN_EXE_ledger-gui"));
|
||||||
c.env_clear().env("PATH", "/usr/bin:/bin").env("HOME", home);
|
c.env_clear().env("PATH", "/usr/bin:/bin").env("HOME", home);
|
||||||
|
// A Windows program started with no environment at all can't load.
|
||||||
|
#[cfg(windows)]
|
||||||
|
for name in ["SystemRoot", "windir"] {
|
||||||
|
if let Some(value) = std::env::var_os(name) {
|
||||||
|
c.env(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use ledger_gui::data::{self, Loader, Update};
|
|||||||
use ledger_gui::nav::{ReportKind, Route};
|
use ledger_gui::nav::{ReportKind, Route};
|
||||||
use ledger_gui::{App, Source};
|
use ledger_gui::{App, Source};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
@@ -46,22 +47,36 @@ fn tree(root: &Path) -> Tree {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Every file 0444 and folder 0555 (`read_only`), or back to 0644/0755.
|
/// Every file 0444 and folder 0555 (`read_only`), or back to 0644/0755.
|
||||||
|
/// On Windows a file gets its read-only attribute (which stops a write
|
||||||
|
/// to it) and a folder is left as it is: a folder can't be made
|
||||||
|
/// read-only there.
|
||||||
fn set_read_only(root: &Path, read_only: bool) {
|
fn set_read_only(root: &Path, read_only: bool) {
|
||||||
fn walk(p: &Path, ro: bool) {
|
fn walk(p: &Path, ro: bool) {
|
||||||
let m = std::fs::symlink_metadata(p).unwrap();
|
let m = std::fs::symlink_metadata(p).unwrap();
|
||||||
if m.is_dir() {
|
if m.is_dir() {
|
||||||
|
#[cfg(unix)]
|
||||||
if !ro {
|
if !ro {
|
||||||
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
}
|
}
|
||||||
for e in std::fs::read_dir(p).unwrap() {
|
for e in std::fs::read_dir(p).unwrap() {
|
||||||
walk(&e.unwrap().path(), ro);
|
walk(&e.unwrap().path(), ro);
|
||||||
}
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
if ro {
|
if ro {
|
||||||
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o555)).unwrap();
|
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let mode = if ro { 0o444 } else { 0o644 };
|
#[cfg(unix)]
|
||||||
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap();
|
{
|
||||||
|
let mode = if ro { 0o444 } else { 0o644 };
|
||||||
|
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode)).unwrap();
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let mut permissions = m.permissions();
|
||||||
|
permissions.set_readonly(ro);
|
||||||
|
std::fs::set_permissions(p, permissions).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
walk(root, read_only);
|
walk(root, read_only);
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ struct Bridge {
|
|||||||
next_id: std::sync::atomic::AtomicU64,
|
next_id: std::sync::atomic::AtomicU64,
|
||||||
/// The agent's requests not yet answered, by id (INVARIANT 8).
|
/// The agent's requests not yet answered, by id (INVARIANT 8).
|
||||||
pending: Mutex<std::collections::HashMap<String, Pending>>,
|
pending: Mutex<std::collections::HashMap<String, Pending>>,
|
||||||
/// The server's socket, for its owner check (INVARIANT 9).
|
/// The server's channel, for its owner check (INVARIANT 9).
|
||||||
socket: PathBuf,
|
endpoint: ledger_mcp::ipc::Endpoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Who has the server's socket, when it isn't plainly this account.
|
/// 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).
|
/// Whether the server's socket belongs to this account (INVARIANT 9).
|
||||||
fn socket_is_ours(&self) -> Result<(), Owner> {
|
fn socket_is_ours(&self) -> Result<(), Owner> {
|
||||||
let stream = std::os::unix::net::UnixStream::connect(&self.socket)
|
let stream =
|
||||||
.map_err(|_| Owner::Unreachable)?;
|
ledger_mcp::ipc::connect_std(&self.endpoint).map_err(|_| Owner::Unreachable)?;
|
||||||
if ledger_mcp::local::peer_is_us(&stream) {
|
if ledger_mcp::ipc::peer_is_us(&stream) {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(Owner::Other(
|
Err(Owner::Other(
|
||||||
@@ -1314,18 +1314,16 @@ pub fn run(
|
|||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.to_string();
|
.to_string();
|
||||||
let opener = Opener::new(root.as_deref(), knobs, Kind::Bridge, Some(book.clone()))?;
|
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.
|
// Kept for the whole session: the client reaches the channel by it.
|
||||||
let socket =
|
let endpoint = ledger_mcp::ipc::Endpoint::new(opener.run_dir(), ledger_mcp::local::MCP_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}"))?;
|
||||||
.map_err(|e| format!("can't reach the books server: {e}"))?;
|
|
||||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()?;
|
.build()?;
|
||||||
let result = rt.block_on(async move {
|
let result = rt.block_on(async move {
|
||||||
// A connection per call: one the server closes while idle can't
|
// A connection per call: one the server closes while idle can't
|
||||||
// swallow a call (INVARIANT 4).
|
// swallow a call (INVARIANT 4).
|
||||||
let http = reqwest::Client::builder()
|
let http = crate::channel::http_builder(&endpoint)
|
||||||
.unix_socket(socket.path())
|
|
||||||
.pool_max_idle_per_host(0)
|
.pool_max_idle_per_host(0)
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
.build()
|
.build()
|
||||||
@@ -1347,7 +1345,7 @@ pub fn run(
|
|||||||
tools_seen: Mutex::new(None),
|
tools_seen: Mutex::new(None),
|
||||||
next_id: std::sync::atomic::AtomicU64::new(1),
|
next_id: std::sync::atomic::AtomicU64::new(1),
|
||||||
pending: Mutex::new(std::collections::HashMap::new()),
|
pending: Mutex::new(std::collections::HashMap::new()),
|
||||||
socket: socket.path().to_path_buf(),
|
endpoint,
|
||||||
});
|
});
|
||||||
let keeper = tokio::spawn(bridge.clone().keep(opener));
|
let keeper = tokio::spawn(bridge.clone().keep(opener));
|
||||||
let writer = tokio::spawn(async move {
|
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
|
// A client that stops the bridge with a signal (most do) still has
|
||||||
// its session ended upstream (INVARIANT 10).
|
// its session ended upstream (INVARIANT 10).
|
||||||
let stop = Arc::new(Notify::new());
|
let stop = Arc::new(Notify::new());
|
||||||
for kind in [
|
for mut signals in ledger_mcp::ipc::stop_signals().map_err(|e| e.to_string())? {
|
||||||
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())?;
|
|
||||||
let stop = stop.clone();
|
let stop = stop.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if signals.recv().await.is_some() {
|
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
|
/// The bridge against a scripted server on a Unix socket: every way an
|
||||||
/// answer can come back, and the bridge's own bookkeeping.
|
/// answer can come back, and the bridge's own bookkeeping.
|
||||||
#[cfg(test)]
|
#[cfg(all(test, unix))]
|
||||||
mod against_a_server {
|
mod against_a_server {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::collections::{HashMap, VecDeque};
|
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>) {
|
fn bridge_at(socket: &Path, knobs: Knobs) -> (Arc<Bridge>, mpsc::UnboundedReceiver<Value>) {
|
||||||
let http = reqwest::Client::builder()
|
let endpoint = ledger_mcp::ipc::Endpoint::new(
|
||||||
.unix_socket(socket)
|
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)
|
.pool_max_idle_per_host(0)
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
.build()
|
.build()
|
||||||
@@ -1693,7 +1690,7 @@ mod against_a_server {
|
|||||||
tools_seen: Mutex::new(None),
|
tools_seen: Mutex::new(None),
|
||||||
next_id: std::sync::atomic::AtomicU64::new(1),
|
next_id: std::sync::atomic::AtomicU64::new(1),
|
||||||
pending: Mutex::new(std::collections::HashMap::new()),
|
pending: Mutex::new(std::collections::HashMap::new()),
|
||||||
socket: socket.to_path_buf(),
|
endpoint,
|
||||||
});
|
});
|
||||||
(bridge, rx)
|
(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
|
// Ctrl-C, SIGTERM and SIGHUP are heard from the start, whatever
|
||||||
// serve is doing (opening, waiting to try again): none is missed.
|
// serve is doing (opening, waiting to try again): none is missed.
|
||||||
let stop = std::sync::Arc::new(tokio::sync::Notify::new());
|
let stop = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||||
for kind in [
|
for mut signals in ledger_mcp::ipc::stop_signals()? {
|
||||||
tokio::signal::unix::SignalKind::interrupt(),
|
|
||||||
tokio::signal::unix::SignalKind::terminate(),
|
|
||||||
tokio::signal::unix::SignalKind::hangup(),
|
|
||||||
] {
|
|
||||||
let mut signals = tokio::signal::unix::signal(kind)?;
|
|
||||||
let stop = stop.clone();
|
let stop = stop.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while signals.recv().await.is_some() {
|
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
|
/// or a job in the background, would wait unseen for an answer, and mix
|
||||||
/// the question into its output).
|
/// the question into its output).
|
||||||
fn offer_upgrade(run: &Path, book: &str, waiting: &admin::FormatWaiting) {
|
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.
|
// SAFETY: isatty, tcgetpgrp and getpgrp have no preconditions.
|
||||||
let seen = unsafe { libc::isatty(1) == 1 && libc::tcgetpgrp(0) == libc::getpgrp() };
|
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()) {
|
if !(seen && can_type_a_passphrase()) {
|
||||||
eprintln!(
|
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.",
|
"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.",
|
||||||
@@ -1275,6 +1275,8 @@ pub fn find_agent_in(
|
|||||||
".bun/bin",
|
".bun/bin",
|
||||||
".cargo/bin",
|
".cargo/bin",
|
||||||
".volta/bin",
|
".volta/bin",
|
||||||
|
// Where npm puts global programs on Windows.
|
||||||
|
"AppData/Roaming/npm",
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|d| home.map(|h| h.join(d)))
|
.filter_map(|d| home.map(|h| h.join(d)))
|
||||||
@@ -1288,7 +1290,18 @@ pub fn find_agent_in(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(usual)
|
.chain(usual)
|
||||||
.filter(|d| d.is_absolute())
|
.filter(|d| d.is_absolute())
|
||||||
.map(|d| d.join(cli))
|
.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_program(p))
|
.find(|p| is_program(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1979,8 +1992,7 @@ fn vault_beside(source: &Path) -> Option<PathBuf> {
|
|||||||
|
|
||||||
/// Whether standard input is a terminal.
|
/// Whether standard input is a terminal.
|
||||||
fn at_a_terminal() -> bool {
|
fn at_a_terminal() -> bool {
|
||||||
// SAFETY: isatty has no preconditions.
|
std::io::IsTerminal::is_terminal(&std::io::stdin())
|
||||||
unsafe { libc::isatty(0) == 1 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `wanderbooks books adopt` (INVARIANT 3).
|
/// `wanderbooks books adopt` (INVARIANT 3).
|
||||||
@@ -2766,10 +2778,9 @@ fn on_the_socket(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let token = zeroize::Zeroizing::new(token);
|
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}"))?;
|
.map_err(|e| format!("can't reach the books server: {e}"))?;
|
||||||
let http = reqwest::Client::builder()
|
let http = crate::channel::http_builder(&socket)
|
||||||
.unix_socket(socket.path())
|
|
||||||
.no_proxy()
|
.no_proxy()
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|||||||
+145
-70
@@ -53,6 +53,7 @@ use crate::knobs::{FLAG, Knobs};
|
|||||||
use crate::lease::{self, Ask, HostMsg, Intent, LeaseInfo, Reason};
|
use crate::lease::{self, Ask, HostMsg, Intent, LeaseInfo, Reason};
|
||||||
use ledger_store::root::Root;
|
use ledger_store::root::Root;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -80,7 +81,23 @@ const LOG_LIMIT: u64 = 8 * 1024 * 1024;
|
|||||||
/// The exit code when another host holds the root.
|
/// The exit code when another host holds the root.
|
||||||
pub const EXIT_ANOTHER_HOST: i32 = 3;
|
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).
|
/// Close every descriptor above 2 (INVARIANT 2).
|
||||||
|
#[cfg(unix)]
|
||||||
fn close_stray_descriptors() {
|
fn close_stray_descriptors() {
|
||||||
#[cfg(target_os = "linux")]
|
#[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()));
|
let probe = run.join(format!(".host-probe-{}", std::process::id()));
|
||||||
match std::fs::OpenOptions::new()
|
match private_options().write(true).create_new(true).open(&probe) {
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(&probe)
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let _ = std::fs::remove_file(&probe);
|
let _ = std::fs::remove_file(&probe);
|
||||||
None
|
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).
|
/// [`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> {
|
fn take_host_lock_within(run: &Path, wait: Duration) -> Result<Option<std::fs::File>, String> {
|
||||||
let path = run.join(lease::HOST_LOCK);
|
let path = run.join(lease::HOST_LOCK);
|
||||||
let file = std::fs::OpenOptions::new()
|
let file = private_options()
|
||||||
.create(true)
|
.create(true)
|
||||||
.truncate(false)
|
.truncate(false)
|
||||||
.write(true)
|
.write(true)
|
||||||
.mode(0o600)
|
|
||||||
.open(&path)
|
.open(&path)
|
||||||
.map_err(|e| format!("can't open the host lock: {}", e.kind()))?;
|
.map_err(|e| format!("can't open the host lock: {}", e.kind()))?;
|
||||||
let deadline = std::time::Instant::now() + wait;
|
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) {
|
if std::fs::metadata(path).is_ok_and(|m| m.len() > limit) {
|
||||||
let _ = std::fs::rename(path, path.with_extension("log.1"));
|
let _ = std::fs::rename(path, path.with_extension("log.1"));
|
||||||
}
|
}
|
||||||
std::fs::OpenOptions::new()
|
private_options().create(true).append(true).open(path)
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The executable the server is started from (INVARIANT 3).
|
/// The executable the server is started from (INVARIANT 3).
|
||||||
@@ -198,8 +205,13 @@ fn children_executable() -> PathBuf {
|
|||||||
|
|
||||||
/// A program file's identity: device and inode, size and last write. A
|
/// A program file's identity: device and inode, size and last write. A
|
||||||
/// replaced file differs in the inode, and a rewritten one in the rest.
|
/// replaced file differs in the inode, and a rewritten one in the rest.
|
||||||
|
#[cfg(unix)]
|
||||||
type Identity = (u64, u64, u64, i64, i64);
|
type Identity = (u64, u64, u64, i64, i64);
|
||||||
|
/// Windows: size and last write (nanoseconds).
|
||||||
|
#[cfg(windows)]
|
||||||
|
type Identity = (u64, u64);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
fn identity_from(meta: &std::fs::Metadata) -> Identity {
|
fn identity_from(meta: &std::fs::Metadata) -> Identity {
|
||||||
(
|
(
|
||||||
meta.dev(),
|
meta.dev(),
|
||||||
@@ -213,6 +225,7 @@ fn identity_from(meta: &std::fs::Metadata) -> Identity {
|
|||||||
/// The running executable's file identity, and its file's now: they
|
/// The running executable's file identity, and its file's now: they
|
||||||
/// differ once the file was replaced (INVARIANT 7). An AppImage is its
|
/// differ once the file was replaced (INVARIANT 7). An AppImage is its
|
||||||
/// image file (`APPIMAGE`), not the program inside the mount.
|
/// image file (`APPIMAGE`), not the program inside the mount.
|
||||||
|
#[cfg(unix)]
|
||||||
fn running_identity() -> Option<Identity> {
|
fn running_identity() -> Option<Identity> {
|
||||||
if let Some(image) = crate::start::own_appimage(std::env::var_os("APPIMAGE").as_deref()) {
|
if let Some(image) = crate::start::own_appimage(std::env::var_os("APPIMAGE").as_deref()) {
|
||||||
return Some(identity_from(&std::fs::metadata(image).ok()?));
|
return Some(identity_from(&std::fs::metadata(image).ok()?));
|
||||||
@@ -225,6 +238,14 @@ fn running_identity() -> Option<Identity> {
|
|||||||
Some(identity_from(&running))
|
Some(identity_from(&running))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// 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).
|
/// program (a file half-written or gone doesn't count yet).
|
||||||
fn file_identity() -> Option<Identity> {
|
fn file_identity() -> Option<Identity> {
|
||||||
@@ -233,11 +254,30 @@ fn file_identity() -> Option<Identity> {
|
|||||||
|
|
||||||
/// The identity of the program at `path`: a file someone may run; else
|
/// The identity of the program at `path`: a file someone may run; else
|
||||||
/// None.
|
/// None.
|
||||||
|
#[cfg(unix)]
|
||||||
fn identity_of(path: &Path) -> Option<Identity> {
|
fn identity_of(path: &Path) -> Option<Identity> {
|
||||||
let meta = std::fs::metadata(path).ok()?;
|
let meta = std::fs::metadata(path).ok()?;
|
||||||
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| identity_from(&meta))
|
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| identity_from(&meta))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// Wait at most `wait` for a host to answer on the lease socket: whether
|
||||||
/// one did.
|
/// one did.
|
||||||
fn wait_for_a_host(run: &Path, wait: Duration) -> bool {
|
fn wait_for_a_host(run: &Path, wait: Duration) -> bool {
|
||||||
@@ -365,19 +405,29 @@ pub fn run(
|
|||||||
/// Start the next host from the file now on disk, with this one's
|
/// Start the next host from the file now on disk, with this one's
|
||||||
/// arguments and (already clean) environment (INVARIANT 7).
|
/// arguments and (already clean) environment (INVARIANT 7).
|
||||||
fn restart() -> Result<(), String> {
|
fn restart() -> Result<(), String> {
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
let exe = crate::start::this_executable()?;
|
let exe = crate::start::this_executable()?;
|
||||||
let mut c = std::process::Command::new(exe);
|
let mut c = std::process::Command::new(exe);
|
||||||
c.args(std::env::args_os().skip(1))
|
c.args(std::env::args_os().skip(1))
|
||||||
.current_dir("/")
|
|
||||||
.stdin(std::process::Stdio::null())
|
.stdin(std::process::Stdio::null())
|
||||||
.stdout(std::process::Stdio::null());
|
.stdout(std::process::Stdio::null());
|
||||||
// SAFETY: setsid is async-signal-safe and touches only the child.
|
#[cfg(unix)]
|
||||||
unsafe {
|
{
|
||||||
c.pre_exec(|| {
|
use std::os::unix::process::CommandExt;
|
||||||
libc::setsid();
|
c.current_dir("/");
|
||||||
Ok(())
|
// 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()
|
c.spawn()
|
||||||
.map(drop)
|
.map(drop)
|
||||||
@@ -474,20 +524,11 @@ impl Host {
|
|||||||
/// Bind the lease socket, start the server, and serve leases until
|
/// Bind the lease socket, start the server, and serve leases until
|
||||||
/// the books close.
|
/// the books close.
|
||||||
async fn serve(mut self) -> Result<Ending, String> {
|
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 (events, mut inbox) = mpsc::unbounded_channel::<Event>();
|
||||||
let connections = TaskTracker::new();
|
let connections = TaskTracker::new();
|
||||||
let mut signals = Vec::new();
|
let signals =
|
||||||
for kind in [
|
ledger_mcp::ipc::stop_signals().map_err(|e| format!("can't watch for signals: {e}"))?;
|
||||||
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 (stop_tx, mut stop) = mpsc::unbounded_channel::<()>();
|
let (stop_tx, mut stop) = mpsc::unbounded_channel::<()>();
|
||||||
for mut s in signals {
|
for mut s in signals {
|
||||||
let stop_tx = stop_tx.clone();
|
let stop_tx = stop_tx.clone();
|
||||||
@@ -505,6 +546,7 @@ impl Host {
|
|||||||
let mut update_poll =
|
let mut update_poll =
|
||||||
tokio::time::interval((self.knobs.update_check / 4).max(Duration::from_millis(10)));
|
tokio::time::interval((self.knobs.update_check / 4).max(Duration::from_millis(10)));
|
||||||
update_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
update_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
#[cfg(unix)]
|
||||||
let looking = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
let looking = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
let mut idle_at = Some(Instant::now() + self.knobs.close_after);
|
let mut idle_at = Some(Instant::now() + self.knobs.close_after);
|
||||||
let server_start = tokio::time::sleep(self.knobs.server_delay);
|
let server_start = tokio::time::sleep(self.knobs.server_delay);
|
||||||
@@ -537,9 +579,9 @@ impl Host {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
accepted = listener.accept() => match accepted {
|
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.
|
// 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()));
|
connections.spawn(connection(stream, pid, events.clone()));
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
@@ -664,17 +706,22 @@ impl Host {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = update_poll.tick(), if matches!(self.phase, Phase::Open) => {
|
_ = update_poll.tick(), if matches!(self.phase, Phase::Open) => {
|
||||||
use std::sync::atomic::Ordering;
|
// Staging an update replaces the program's file, which
|
||||||
if !looking.swap(true, Ordering::SeqCst) {
|
// Windows forbids while it runs: P11a step 5.
|
||||||
let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone());
|
#[cfg(unix)]
|
||||||
std::thread::spawn(move || {
|
{
|
||||||
match crate::update::stage_if_due(&root, crate::update::program_version(), every) {
|
use std::sync::atomic::Ordering;
|
||||||
Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"),
|
if !looking.swap(true, Ordering::SeqCst) {
|
||||||
Ok(None) => {}
|
let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone());
|
||||||
Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"),
|
std::thread::spawn(move || {
|
||||||
}
|
match crate::update::stage_if_due(&root, crate::update::program_version(), every) {
|
||||||
looking.store(false, Ordering::SeqCst);
|
Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"),
|
||||||
});
|
Ok(None) => {}
|
||||||
|
Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"),
|
||||||
|
}
|
||||||
|
looking.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = exe_poll.tick(), if matches!(self.phase, Phase::Open) => {
|
_ = exe_poll.tick(), if matches!(self.phase, Phase::Open) => {
|
||||||
@@ -712,7 +759,9 @@ impl Host {
|
|||||||
let log = open_log(&self.run.join(SERVER_LOG))
|
let log = open_log(&self.run.join(SERVER_LOG))
|
||||||
.map_err(|e| format!("can't open the server's log: {}", e.kind()))?;
|
.map_err(|e| format!("can't open the server's log: {}", e.kind()))?;
|
||||||
let mut command = tokio::process::Command::new(children_executable());
|
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 {
|
if self.explicit_root {
|
||||||
command.arg("--root").arg(self.root.path());
|
command.arg("--root").arg(self.root.path());
|
||||||
}
|
}
|
||||||
@@ -853,11 +902,10 @@ fn consented(socket: &Path, book: String, passphrase: ledger_mcp::admin::Passphr
|
|||||||
|
|
||||||
/// Take run/update.lock (exclusive), if it is free now.
|
/// Take run/update.lock (exclusive), if it is free now.
|
||||||
fn take_update_lock(run: &Path) -> Result<std::fs::File, String> {
|
fn take_update_lock(run: &Path) -> Result<std::fs::File, String> {
|
||||||
let file = std::fs::OpenOptions::new()
|
let file = private_options()
|
||||||
.create(true)
|
.create(true)
|
||||||
.truncate(false)
|
.truncate(false)
|
||||||
.write(true)
|
.write(true)
|
||||||
.mode(0o600)
|
|
||||||
.open(run.join(lease::UPDATE_LOCK))
|
.open(run.join(lease::UPDATE_LOCK))
|
||||||
.map_err(|e| format!("can't open the update lock: {}", e.kind()))?;
|
.map_err(|e| format!("can't open the update lock: {}", e.kind()))?;
|
||||||
match file.try_lock() {
|
match file.try_lock() {
|
||||||
@@ -872,31 +920,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
|
/// Bind run/lease.sock, replacing a stale one (only the host holding the
|
||||||
/// lock gets here).
|
/// lock gets here); on Windows the lease pipe of the root.
|
||||||
fn bind(run: &Path) -> Result<tokio::net::UnixListener, String> {
|
fn bind(run: &Path) -> Result<ledger_mcp::ipc::Listener, String> {
|
||||||
let path = run.join(lease::LEASE_SOCKET);
|
let endpoint = ledger_mcp::ipc::Endpoint::new(run, 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)
|
|
||||||
.map_err(|e| format!("can't make the lease socket: {e}"))?;
|
.map_err(|e| format!("can't make the lease socket: {e}"))?;
|
||||||
let listener = tokio::net::UnixListener::bind(short.path())
|
ledger_mcp::ipc::Listener::bind(&endpoint)
|
||||||
.map_err(|e| format!("can't make the lease socket: {}", e.kind()))?;
|
.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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One connection: its first line, then a lease or one answer
|
/// One connection: its first line, then a lease or one answer
|
||||||
/// (lease.rs INVARIANT 1).
|
/// (lease.rs INVARIANT 1).
|
||||||
async fn connection(
|
async fn connection(
|
||||||
stream: tokio::net::UnixStream,
|
stream: ledger_mcp::ipc::Accepted,
|
||||||
peer_pid: Option<u32>,
|
peer_pid: Option<u32>,
|
||||||
events: mpsc::UnboundedSender<Event>,
|
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 reader = tokio::io::BufReader::new(read.take(lease::MAX_LINE as u64 + 1));
|
||||||
let mut first = String::new();
|
let mut first = String::new();
|
||||||
let answer = |msg: HostMsg| lease::line(&msg);
|
let answer = |msg: HostMsg| lease::line(&msg);
|
||||||
@@ -1057,13 +1096,31 @@ mod tests {
|
|||||||
"123456"
|
"123456"
|
||||||
);
|
);
|
||||||
assert_eq!(std::fs::read_to_string(&log).unwrap(), "", "a new log");
|
assert_eq!(std::fs::read_to_string(&log).unwrap(), "", "a new log");
|
||||||
use std::os::unix::fs::PermissionsExt;
|
#[cfg(unix)]
|
||||||
assert_eq!(
|
{
|
||||||
std::fs::metadata(&log).unwrap().permissions().mode() & 0o777,
|
use std::os::unix::fs::PermissionsExt;
|
||||||
0o600
|
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]
|
#[test]
|
||||||
fn a_folder_the_host_can_write_isnt_a_sandbox() {
|
fn a_folder_the_host_can_write_isnt_a_sandbox() {
|
||||||
let seccomp = std::fs::read_to_string("/proc/self/status")
|
let seccomp = std::fs::read_to_string("/proc/self/status")
|
||||||
@@ -1099,6 +1156,24 @@ mod tests {
|
|||||||
assert_eq!(confined(&dir.path().join("gone")), None);
|
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]
|
#[test]
|
||||||
fn only_a_program_file_has_an_identity() {
|
fn only_a_program_file_has_an_identity() {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
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
|
//! viewer's Books menu does). Agents' tokens are kept in files under the data root
|
||||||
//! ([`tokens`]).
|
//! ([`tokens`]).
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
#[cfg(unix)]
|
pub mod channel;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
#[cfg(unix)]
|
|
||||||
pub mod host;
|
pub mod host;
|
||||||
pub mod knobs;
|
pub mod knobs;
|
||||||
pub mod lease;
|
pub mod lease;
|
||||||
#[cfg(unix)]
|
|
||||||
pub mod opener;
|
pub mod opener;
|
||||||
#[cfg(unix)]
|
|
||||||
mod start;
|
mod start;
|
||||||
pub mod tokens;
|
pub mod tokens;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ pub struct Opener {
|
|||||||
pub struct Lease {
|
pub struct Lease {
|
||||||
/// Its number.
|
/// Its number.
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<tokio::net::unix::OwnedReadHalf>>>,
|
lines: tokio::io::Lines<tokio::io::BufReader<tokio::io::Take<ledger_mcp::ipc::ConnectedRead>>>,
|
||||||
_write: tokio::net::unix::OwnedWriteHalf,
|
_write: ledger_mcp::ipc::ConnectedWrite,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Lease {
|
impl Lease {
|
||||||
@@ -207,11 +207,11 @@ impl Opener {
|
|||||||
let mut started: Option<Instant> = None;
|
let mut started: Option<Instant> = None;
|
||||||
loop {
|
loop {
|
||||||
wait_for_restart(&self.run).await;
|
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}"))?;
|
.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) => {
|
Ok(stream) => {
|
||||||
if !ledger_mcp::local::peer_is_us(&stream) {
|
if !ledger_mcp::ipc::peer_is_us(&stream) {
|
||||||
return Err(
|
return Err(
|
||||||
"the books' host socket belongs to another account: not used".into(),
|
"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.
|
/// Say hello on a connection and read the host's first word.
|
||||||
async fn ask(&self, stream: tokio::net::UnixStream) -> Result<Asked, String> {
|
async fn ask(&self, stream: ledger_mcp::ipc::Connected) -> Result<Asked, String> {
|
||||||
let (read, mut write) = stream.into_split();
|
let (read, mut write) = ledger_mcp::ipc::split_connected(stream);
|
||||||
let hello = Hello {
|
let hello = Hello {
|
||||||
proto: lease::PROTO,
|
proto: lease::PROTO,
|
||||||
ask: Ask::Hold {
|
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).
|
/// [`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> {
|
pub fn ask_once_within(run: &Path, ask: Ask, wait: Duration) -> Result<Option<HostMsg>, String> {
|
||||||
use std::io::{BufRead, Write};
|
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}"))?;
|
.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,
|
Ok(s) => s,
|
||||||
Err(e) if no_host(&e) => return Ok(None),
|
Err(e) if no_host(&e) => return Ok(None),
|
||||||
Err(e) => return Err(format!("can't reach the books' host: {}", e.kind())),
|
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());
|
return Err("the books' host socket belongs to another account: not used".into());
|
||||||
}
|
}
|
||||||
stream
|
stream
|
||||||
|
|||||||
@@ -33,18 +33,34 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::knobs::Knobs;
|
use crate::knobs::Knobs;
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||||
|
#[cfg(unix)]
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
#[cfg(unix)]
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
/// The host's PATH (INVARIANT 1).
|
/// The host's PATH (INVARIANT 1).
|
||||||
|
#[cfg(unix)]
|
||||||
const PATH: &str = "/usr/local/bin:/usr/bin:/bin";
|
const PATH: &str = "/usr/local/bin:/usr/bin:/bin";
|
||||||
|
|
||||||
/// Where systemd-run may be (never looked up on the opener's PATH).
|
/// 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"];
|
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/`.
|
/// The host's log, in the root's `run/`.
|
||||||
pub const HOST_LOG: &str = "host.log";
|
pub const HOST_LOG: &str = "host.log";
|
||||||
/// A log larger than this is moved aside when a host starts.
|
/// A log larger than this is moved aside when a host starts.
|
||||||
@@ -75,11 +91,13 @@ pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsStrin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// This account's user ID.
|
/// This account's user ID.
|
||||||
|
#[cfg(unix)]
|
||||||
fn own_uid() -> u32 {
|
fn own_uid() -> u32 {
|
||||||
// SAFETY: geteuid has no preconditions and can't fail.
|
// SAFETY: geteuid has no preconditions and can't fail.
|
||||||
unsafe { libc::geteuid() }
|
unsafe { libc::geteuid() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private
|
/// `XDG_RUNTIME_DIR` from `value`, if it is this account's own private
|
||||||
/// runtime folder: absolute, a real folder (no link), owned by this
|
/// runtime folder: absolute, a real folder (no link), owned by this
|
||||||
/// account, and closed to everyone else.
|
/// account, and closed to everyone else.
|
||||||
@@ -94,6 +112,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)
|
(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`
|
/// `DBUS_SESSION_BUS_ADDRESS` from `value`, if it is the bus in `runtime`
|
||||||
/// (`unix:path=<runtime>/bus`, perhaps with a guid after a comma).
|
/// (`unix:path=<runtime>/bus`, perhaps with a guid after a comma).
|
||||||
pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String> {
|
pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String> {
|
||||||
@@ -103,6 +122,7 @@ pub fn own_bus(value: Option<&std::ffi::OsStr>, runtime: &Path) -> Option<String
|
|||||||
(rest.is_empty() || rest.starts_with(',')).then(|| value.to_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
|
/// 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
|
/// 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,
|
/// client that scrubs it, as Codex does), the system's for this account,
|
||||||
@@ -115,6 +135,7 @@ fn runtime_dir() -> Option<PathBuf> {
|
|||||||
.or_else(|| own_runtime_dir(Some(format!("/run/user/{}", own_uid()).as_ref())))
|
.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
|
/// The session bus in `runtime`: the one named in the environment if it
|
||||||
/// is there, else its usual socket there, if one exists.
|
/// is there, else its usual socket there, if one exists.
|
||||||
fn session_bus(runtime: &Path) -> Option<String> {
|
fn session_bus(runtime: &Path) -> Option<String> {
|
||||||
@@ -130,6 +151,7 @@ fn session_bus(runtime: &Path) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
/// The ssh agent's socket named in `named`, if it is a socket this
|
/// The ssh agent's socket named in `named`, if it is a socket this
|
||||||
/// account owns at an absolute path (INVARIANT 1).
|
/// account owns at an absolute path (INVARIANT 1).
|
||||||
fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
|
fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
|
||||||
@@ -141,6 +163,7 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
|
|||||||
.then(|| path.as_os_str().to_os_string())
|
.then(|| path.as_os_str().to_os_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
/// The host's environment (INVARIANT 1), given the opener's
|
/// The host's environment (INVARIANT 1), given the opener's
|
||||||
/// `SSH_AUTH_SOCK`.
|
/// `SSH_AUTH_SOCK`.
|
||||||
fn host_env(
|
fn host_env(
|
||||||
@@ -167,6 +190,7 @@ fn host_env(
|
|||||||
env
|
env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
/// Whether this process sits in a unit under this account's user manager
|
/// 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
|
/// (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
|
/// would take the host with it; going by the unified cgroup line of
|
||||||
@@ -182,6 +206,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
|
/// systemd-run, if this process is in an app scope and the user manager
|
||||||
/// can be reached (INVARIANT 3).
|
/// can be reached (INVARIANT 3).
|
||||||
fn systemd_run() -> Option<&'static str> {
|
fn systemd_run() -> Option<&'static str> {
|
||||||
@@ -195,6 +220,7 @@ fn systemd_run() -> Option<&'static str> {
|
|||||||
SYSTEMD_RUN.into_iter().find(|p| Path::new(p).is_file())
|
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
|
/// Start a host for the root (`root`, else the one the host finds
|
||||||
/// itself), with these knobs and these server flags (INVARIANTS 1-5).
|
/// itself), with these knobs and these server flags (INVARIANTS 1-5).
|
||||||
pub fn start_host(
|
pub fn start_host(
|
||||||
@@ -293,9 +319,11 @@ pub fn start_host(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
/// How long systemd-run has to make its scope and run the host.
|
/// How long systemd-run has to make its scope and run the host.
|
||||||
const SCOPE_WAIT: Duration = Duration::from_secs(2);
|
const SCOPE_WAIT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
/// Whether systemd-run made its scope and ran the host (INVARIANT 3): the
|
/// 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
|
/// 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
|
/// running when SCOPE_WAIT is up (it became the host). Only systemd-run
|
||||||
@@ -325,6 +353,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).
|
/// Reap a started host when it ends, without waiting for it (INVARIANT 5).
|
||||||
fn reap(mut child: std::process::Child) {
|
fn reap(mut child: std::process::Child) {
|
||||||
let _ = std::thread::Builder::new()
|
let _ = std::thread::Builder::new()
|
||||||
@@ -334,7 +467,7 @@ fn reap(mut child: std::process::Child) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(all(test, unix))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
@@ -501,3 +634,44 @@ mod tests {
|
|||||||
assert!(exe.is_file(), "{exe:?}");
|
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))
|
std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o700))
|
||||||
.map_err(refused)?;
|
.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 name = account.file_name();
|
||||||
let tmp = self.0.join(format!("{name}.tmp{}", std::process::id()));
|
let tmp = self.0.join(format!("{name}.tmp{}", std::process::id()));
|
||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Books homes (P11b, D-27): the folder an agent works in for a book. It
|
//! Books homes (P11b, D-27): the folder an agent works in for a book. It
|
||||||
//! holds the agent's pointer and settings and nothing of the books.
|
//! holds the agent's pointer and settings and nothing of the books.
|
||||||
|
|
||||||
|
#[cfg_attr(not(unix), allow(unused_imports))]
|
||||||
use ledger_host::commands::{
|
use ledger_host::commands::{
|
||||||
AgentStart, books_home_in, find_agent_in, installer, make_books_home_in, start_agent_in,
|
AgentStart, books_home_in, find_agent_in, installer, make_books_home_in, start_agent_in,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
//! The books' named pipes on Windows (P11c, D-62, plan 3.9): what keeps a
|
||||||
|
//! second account out of them, in the two ways the design has (the pipe's
|
||||||
|
//! DACL, and the SID check of the process at the other end). Windows job
|
||||||
|
//! only.
|
||||||
|
#![cfg(windows)]
|
||||||
|
|
||||||
|
use ledger_mcp::ipc::{Endpoint, Listener};
|
||||||
|
use ledger_store::winsec;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// The process id of `image` (a system program), if `tasklist` finds it.
|
||||||
|
fn pid_of(image: &str) -> Option<u32> {
|
||||||
|
let out = std::process::Command::new("tasklist")
|
||||||
|
.args(["/FI", &format!("IMAGENAME eq {image}"), "/FO", "CSV", "/NH"])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
// "services.exe","700","Services","0","12,345 K"
|
||||||
|
text.lines()
|
||||||
|
.find(|l| l.to_lowercase().contains(&image.to_lowercase()))?
|
||||||
|
.split(',')
|
||||||
|
.nth(1)?
|
||||||
|
.trim_matches('"')
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_second_user_cannot_open_the_pipe() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let endpoint = Endpoint::new(dir.path(), "lease.sock").unwrap();
|
||||||
|
let _listener = Listener::bind(&endpoint).unwrap();
|
||||||
|
|
||||||
|
// The pipe's own DACL names this account and no one else: Everyone,
|
||||||
|
// Users and Authenticated Users are not on it, so the system itself
|
||||||
|
// refuses another account's open.
|
||||||
|
let pipe = Path::new(endpoint.pipe_name());
|
||||||
|
assert!(
|
||||||
|
winsec::is_private(pipe).unwrap(),
|
||||||
|
"the pipe is open to more than this account"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a process of another account is never taken for this one: this
|
||||||
|
// account's own is, and a system service (SYSTEM) is not.
|
||||||
|
assert!(winsec::pid_is_us(std::process::id()));
|
||||||
|
if let Some(pid) = pid_of("services.exe") {
|
||||||
|
assert!(
|
||||||
|
!winsec::pid_is_us(pid),
|
||||||
|
"a SYSTEM process ({pid}) was taken for this account"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!winsec::pid_is_us(0));
|
||||||
|
}
|
||||||
@@ -521,14 +521,12 @@ impl std::fmt::Display for CallError {
|
|||||||
|
|
||||||
/// Ask the server on `socket` (the root's `run/admin.sock`), waiting as
|
/// Ask the server on `socket` (the root's `run/admin.sock`), waiting as
|
||||||
/// long as it takes (a create or an adoption).
|
/// long as it takes (a create or an adoption).
|
||||||
#[cfg(unix)]
|
|
||||||
pub fn call(socket: &std::path::Path, request: Request) -> Result<Response, CallError> {
|
pub fn call(socket: &std::path::Path, request: Request) -> Result<Response, CallError> {
|
||||||
call_within(socket, request, None)
|
call_within(socket, request, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`call`], waiting at most `wait` for the answer (a quick look, such
|
/// [`call`], waiting at most `wait` for the answer (a quick look, such
|
||||||
/// as `doctor`'s).
|
/// as `doctor`'s).
|
||||||
#[cfg(unix)]
|
|
||||||
pub fn call_within(
|
pub fn call_within(
|
||||||
socket: &std::path::Path,
|
socket: &std::path::Path,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -574,15 +572,14 @@ pub fn call_within(
|
|||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
);
|
);
|
||||||
let short = ledger_store::root::SocketPath::new(dir, name)
|
let endpoint = crate::ipc::Endpoint::new(dir, name)
|
||||||
.map_err(|e| not_sent(format!("can't reach the books server: {e}")))?;
|
.map_err(|e| not_sent(format!("can't reach the books server: {e}")))?;
|
||||||
let stream =
|
let stream = crate::ipc::connect_std(&endpoint).map_err(|e| match e.kind() {
|
||||||
std::os::unix::net::UnixStream::connect(short.path()).map_err(|e| match e.kind() {
|
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => {
|
||||||
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => {
|
not_sent("the books server isn't running".to_string())
|
||||||
not_sent("the books server isn't running".to_string())
|
}
|
||||||
}
|
_ => not_sent(format!("can't reach the books server: {}", e.kind())),
|
||||||
_ => not_sent(format!("can't reach the books server: {}", e.kind())),
|
})?;
|
||||||
})?;
|
|
||||||
crate::local::check_peer_std(&stream).map_err(not_sent)?;
|
crate::local::check_peer_std(&stream).map_err(not_sent)?;
|
||||||
stream
|
stream
|
||||||
.set_read_timeout(wait)
|
.set_read_timeout(wait)
|
||||||
@@ -896,26 +893,38 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fake books server on the socket `name` in `dir`: it takes one
|
/// A fake books server on the channel `name` in `dir`: it takes one
|
||||||
/// request and answers `answer`, byte for byte.
|
/// request and answers `answer`, byte for byte.
|
||||||
#[cfg(unix)]
|
|
||||||
fn answering(
|
fn answering(
|
||||||
dir: &std::path::Path,
|
dir: &std::path::Path,
|
||||||
name: &str,
|
name: &str,
|
||||||
answer: Vec<u8>,
|
answer: Vec<u8>,
|
||||||
) -> (std::path::PathBuf, std::thread::JoinHandle<()>) {
|
) -> (std::path::PathBuf, std::thread::JoinHandle<()>) {
|
||||||
let socket = dir.join(name);
|
let socket = dir.join(name);
|
||||||
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
let endpoint = crate::ipc::Endpoint::new(dir, name).unwrap();
|
||||||
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
||||||
let server = std::thread::spawn(move || {
|
let server = std::thread::spawn(move || {
|
||||||
use std::io::{BufRead, Write};
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
let (stream, _) = listener.accept().unwrap();
|
.enable_all()
|
||||||
let mut request = String::new();
|
.build()
|
||||||
std::io::BufReader::new(&stream)
|
|
||||||
.read_line(&mut request)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// A client that stops reading part way breaks the pipe: fine.
|
rt.block_on(async move {
|
||||||
let _ = (&stream).write_all(&answer);
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||||
|
let mut listener = crate::ipc::Listener::bind(&endpoint).unwrap();
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
let stream = listener.accept().await.unwrap();
|
||||||
|
let (read, mut write) = crate::ipc::split_accepted(stream);
|
||||||
|
let mut request = String::new();
|
||||||
|
tokio::io::BufReader::new(read)
|
||||||
|
.read_line(&mut request)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// A client that stops reading part way breaks the pipe: fine.
|
||||||
|
let _ = write.write_all(&answer).await;
|
||||||
|
let _ = write.flush().await;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
(socket, server)
|
(socket, server)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,7 +940,6 @@ mod tests {
|
|||||||
/// newline not counted, is taken whole (settings texts ride in them);
|
/// newline not counted, is taken whole (settings texts ride in them);
|
||||||
/// a byte more is refused, as is an answer cut off before its
|
/// a byte more is refused, as is an answer cut off before its
|
||||||
/// newline, even when what came parses.
|
/// newline, even when what came parses.
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_answer_is_one_line_of_at_most_four_mib() {
|
fn an_answer_is_one_line_of_at_most_four_mib() {
|
||||||
let four_mib = 4 * 1024 * 1024;
|
let four_mib = 4 * 1024 * 1024;
|
||||||
@@ -956,7 +964,6 @@ mod tests {
|
|||||||
/// No server on the socket: the request never left, and the command
|
/// No server on the socket: the request never left, and the command
|
||||||
/// line says the server isn't running, whether there is no socket or
|
/// line says the server isn't running, whether there is no socket or
|
||||||
/// one a stopped server left behind.
|
/// one a stopped server left behind.
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_server_means_nothing_was_sent() {
|
fn no_server_means_nothing_was_sent() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -965,17 +972,21 @@ mod tests {
|
|||||||
call(&dir.path().join("admin.sock"), Request::List),
|
call(&dir.path().join("admin.sock"), Request::List),
|
||||||
not_running
|
not_running
|
||||||
);
|
);
|
||||||
let stale = dir.path().join("stale.sock");
|
// (A pipe has no stale file to leave behind: only a socket does.)
|
||||||
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
|
#[cfg(unix)]
|
||||||
assert_eq!(
|
{
|
||||||
call(
|
let stale = dir.path().join("stale.sock");
|
||||||
&stale,
|
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
|
||||||
Request::Create {
|
assert_eq!(
|
||||||
id: "acme".into(),
|
call(
|
||||||
currency: "USD".into(),
|
&stale,
|
||||||
}
|
Request::Create {
|
||||||
),
|
id: "acme".into(),
|
||||||
not_running
|
currency: "USD".into(),
|
||||||
);
|
}
|
||||||
|
),
|
||||||
|
not_running
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,793 @@
|
|||||||
|
//! The local transport, one seam for both families of system (plan 3.9,
|
||||||
|
//! D-62): Unix sockets in the root's `run/` on Unix, named pipes on
|
||||||
|
//! Windows, each reachable by this account alone.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! GOAL: the books server, the host and everything that talks to them
|
||||||
|
//! meet on a channel that only this account can open, and each end
|
||||||
|
//! checks that the other end is this account.
|
||||||
|
//! SOURCE: plan/served.md 3.9 ("the local transport, the peer check":
|
||||||
|
//! 0600 sockets plus the peer's uid; on Windows a pipe with an
|
||||||
|
//! owner-only DACL plus the peer process's SID), D-32 (nothing
|
||||||
|
//! here confines a process), D-62.
|
||||||
|
//! INVARIANTS:
|
||||||
|
//! 1. A listener is made for this account alone: Unix sockets 0600; a
|
||||||
|
//! Windows pipe with a DACL of one allow entry (this account's SID)
|
||||||
|
//! and remote clients refused.
|
||||||
|
//! 2. [`Peer::peer_is_us`] is true only when the system says the other
|
||||||
|
//! end is this account (the effective uid; the user SID of the
|
||||||
|
//! process at the other end of the pipe); any doubt is false.
|
||||||
|
//! 3. A Windows pipe's name carries a hash of the root's `run/` folder,
|
||||||
|
//! so two roots on one machine never share a pipe, and the first
|
||||||
|
//! instance can be made only once (a second server is refused).
|
||||||
|
//! 4. Windows keeps idle pipe instances waiting, so a client never
|
||||||
|
//! finds every instance busy while the server makes the next one.
|
||||||
|
//! 5. A read with a time limit gives up at the limit with `TimedOut`
|
||||||
|
//! (Unix: `WouldBlock`); a pipe whose other end has gone reads as
|
||||||
|
//! the end of the stream.
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
pub use sys::*;
|
||||||
|
|
||||||
|
/// Who is at the other end of a connection.
|
||||||
|
pub trait Peer {
|
||||||
|
/// Whether the other end is this account (INVARIANT 2).
|
||||||
|
fn peer_is_us(&self) -> bool;
|
||||||
|
/// The other end's process, where the system says it.
|
||||||
|
fn peer_pid(&self) -> Option<u32>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the other end of `stream` is this account.
|
||||||
|
pub fn peer_is_us(stream: &impl Peer) -> bool {
|
||||||
|
stream.peer_is_us()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other end's process, where the system says.
|
||||||
|
pub fn peer_pid(stream: &impl Peer) -> Option<u32> {
|
||||||
|
stream.peer_pid()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One of the signals that ask a program to stop: on Unix SIGTERM,
|
||||||
|
/// SIGINT and SIGHUP; on Windows Ctrl+C, Ctrl+Break, closing the console
|
||||||
|
/// and shutdown.
|
||||||
|
pub struct StopSignal(sys::RawStop);
|
||||||
|
|
||||||
|
impl StopSignal {
|
||||||
|
/// The next time it arrives; `None` once it can't arrive again.
|
||||||
|
pub async fn recv(&mut self) -> Option<()> {
|
||||||
|
self.0.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch for every signal that asks a program to stop.
|
||||||
|
pub fn stop_signals() -> io::Result<Vec<StopSignal>> {
|
||||||
|
sys::raw_stops().map(|all| all.into_iter().map(StopSignal).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod sys {
|
||||||
|
use super::Peer;
|
||||||
|
use std::io;
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// A connection a listener accepted.
|
||||||
|
pub type Accepted = tokio::net::UnixStream;
|
||||||
|
/// A connection this side made.
|
||||||
|
pub type Connected = tokio::net::UnixStream;
|
||||||
|
/// A connection this side made, for blocking code.
|
||||||
|
pub type StdStream = std::os::unix::net::UnixStream;
|
||||||
|
/// What [`Listener::addr_of`] says of a connection.
|
||||||
|
pub type Addr = tokio::net::unix::SocketAddr;
|
||||||
|
/// The reading half of an accepted connection.
|
||||||
|
pub type AcceptedRead = tokio::net::unix::OwnedReadHalf;
|
||||||
|
/// The writing half of an accepted connection.
|
||||||
|
pub type AcceptedWrite = tokio::net::unix::OwnedWriteHalf;
|
||||||
|
/// The reading half of a connection this side made.
|
||||||
|
pub type ConnectedRead = tokio::net::unix::OwnedReadHalf;
|
||||||
|
/// The writing half of a connection this side made.
|
||||||
|
pub type ConnectedWrite = tokio::net::unix::OwnedWriteHalf;
|
||||||
|
|
||||||
|
/// Split an accepted connection into its halves.
|
||||||
|
pub fn split_accepted(stream: Accepted) -> (AcceptedRead, AcceptedWrite) {
|
||||||
|
stream.into_split()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a connection this side made into its halves.
|
||||||
|
pub fn split_connected(stream: Connected) -> (ConnectedRead, ConnectedWrite) {
|
||||||
|
stream.into_split()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a channel is: a socket `name` in the root's `run/`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Endpoint {
|
||||||
|
full: PathBuf,
|
||||||
|
short: ledger_store::root::SocketPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Endpoint {
|
||||||
|
/// The channel `name` in the root's `run/` folder `dir`.
|
||||||
|
pub fn new(dir: &Path, name: &str) -> io::Result<Endpoint> {
|
||||||
|
Ok(Endpoint {
|
||||||
|
full: dir.join(name),
|
||||||
|
short: ledger_store::root::SocketPath::new(dir, name)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path to bind or connect to (good while this lives).
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
self.short.path()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A listener that is this account's alone (INVARIANT 1).
|
||||||
|
pub struct Listener(tokio::net::UnixListener);
|
||||||
|
|
||||||
|
impl Listener {
|
||||||
|
/// Bind `endpoint`, replacing a stale socket (only a server that
|
||||||
|
/// holds its lock gets here).
|
||||||
|
pub fn bind(endpoint: &Endpoint) -> io::Result<Listener> {
|
||||||
|
match std::fs::remove_file(&endpoint.full) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
let listener = tokio::net::UnixListener::bind(endpoint.path())?;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&endpoint.full, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
Ok(Listener(listener))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next connection, whoever it is from.
|
||||||
|
pub async fn accept(&mut self) -> io::Result<Accepted> {
|
||||||
|
self.0.accept().await.map(|(stream, _)| stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address of an accepted connection.
|
||||||
|
pub fn addr_of(stream: &Accepted) -> io::Result<Addr> {
|
||||||
|
stream.peer_addr()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address this listener is bound to.
|
||||||
|
pub fn local_addr(&self) -> io::Result<Addr> {
|
||||||
|
self.0.local_addr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to `endpoint`.
|
||||||
|
pub async fn connect(endpoint: &Endpoint) -> io::Result<Connected> {
|
||||||
|
tokio::net::UnixStream::connect(endpoint.path()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to `endpoint`, for blocking code.
|
||||||
|
pub fn connect_std(endpoint: &Endpoint) -> io::Result<StdStream> {
|
||||||
|
std::os::unix::net::UnixStream::connect(endpoint.path())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user ID at the other end of a Unix socket.
|
||||||
|
pub fn peer_uid(fd: &impl AsRawFd) -> io::Result<u32> {
|
||||||
|
let fd = fd.as_raw_fd();
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
|
{
|
||||||
|
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 for writes of the sizes given.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::getsockopt(
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
if len as usize != std::mem::size_of::<libc::ucred>() {
|
||||||
|
return Err(io::Error::other("the peer's credentials came back short"));
|
||||||
|
}
|
||||||
|
Ok(cred.uid)
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
|
{
|
||||||
|
let mut uid: libc::uid_t = 0;
|
||||||
|
let mut gid: libc::gid_t = 0;
|
||||||
|
// SAFETY: `uid` and `gid` are valid for writes.
|
||||||
|
if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } != 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This process's user ID.
|
||||||
|
pub fn own_uid() -> u32 {
|
||||||
|
// SAFETY: geteuid has no preconditions and can't fail.
|
||||||
|
unsafe { libc::geteuid() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pid_of(fd: &impl AsRawFd) -> Option<u32> {
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||||
|
{
|
||||||
|
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 for writes of the sizes given.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::getsockopt(
|
||||||
|
fd.as_raw_fd(),
|
||||||
|
libc::SOL_SOCKET,
|
||||||
|
libc::SO_PEERCRED,
|
||||||
|
(&mut cred as *mut libc::ucred).cast(),
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
(rc == 0 && len as usize == std::mem::size_of::<libc::ucred>() && cred.pid > 0)
|
||||||
|
.then_some(cred.pid as u32)
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||||
|
{
|
||||||
|
let _ = fd;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: AsRawFd> Peer for T {
|
||||||
|
fn peer_is_us(&self) -> bool {
|
||||||
|
peer_uid(self).is_ok_and(|uid| uid == own_uid())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer_pid(&self) -> Option<u32> {
|
||||||
|
pid_of(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One stop signal.
|
||||||
|
pub struct RawStop(tokio::signal::unix::Signal);
|
||||||
|
|
||||||
|
impl RawStop {
|
||||||
|
pub(super) async fn recv(&mut self) -> Option<()> {
|
||||||
|
self.0.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn raw_stops() -> io::Result<Vec<RawStop>> {
|
||||||
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
|
[
|
||||||
|
SignalKind::terminate(),
|
||||||
|
SignalKind::interrupt(),
|
||||||
|
SignalKind::hangup(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|kind| signal(kind).map(RawStop))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod sys {
|
||||||
|
use super::Peer;
|
||||||
|
use ledger_store::winsec;
|
||||||
|
use std::io;
|
||||||
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::net::windows::named_pipe::{
|
||||||
|
ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions,
|
||||||
|
};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// ERROR_PIPE_BUSY: every instance of the pipe is in use for now.
|
||||||
|
const PIPE_BUSY: i32 = 231;
|
||||||
|
/// ERROR_BROKEN_PIPE: the other end has gone.
|
||||||
|
const BROKEN_PIPE: i32 = 109;
|
||||||
|
/// SECURITY_IDENTIFICATION: the server may learn who the client is
|
||||||
|
/// and may not act as the client.
|
||||||
|
const IDENTIFICATION: u32 = 0x0001_0000;
|
||||||
|
/// How long a client waits for a busy pipe (INVARIANT 4).
|
||||||
|
const BUSY_WAIT: Duration = Duration::from_secs(3);
|
||||||
|
/// Instances kept waiting for a client (INVARIANT 4).
|
||||||
|
const IDLE_INSTANCES: usize = 16;
|
||||||
|
|
||||||
|
/// A connection a listener accepted.
|
||||||
|
pub type Accepted = NamedPipeServer;
|
||||||
|
/// A connection this side made.
|
||||||
|
pub type Connected = NamedPipeClient;
|
||||||
|
/// What [`Listener::addr_of`] says of a connection: nothing.
|
||||||
|
pub type Addr = ();
|
||||||
|
/// The reading half of an accepted connection.
|
||||||
|
pub type AcceptedRead = tokio::io::ReadHalf<NamedPipeServer>;
|
||||||
|
/// The writing half of an accepted connection.
|
||||||
|
pub type AcceptedWrite = tokio::io::WriteHalf<NamedPipeServer>;
|
||||||
|
/// The reading half of a connection this side made.
|
||||||
|
pub type ConnectedRead = tokio::io::ReadHalf<NamedPipeClient>;
|
||||||
|
/// The writing half of a connection this side made.
|
||||||
|
pub type ConnectedWrite = tokio::io::WriteHalf<NamedPipeClient>;
|
||||||
|
|
||||||
|
/// Split an accepted connection into its halves.
|
||||||
|
pub fn split_accepted(stream: Accepted) -> (AcceptedRead, AcceptedWrite) {
|
||||||
|
tokio::io::split(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a connection this side made into its halves.
|
||||||
|
pub fn split_connected(stream: Connected) -> (ConnectedRead, ConnectedWrite) {
|
||||||
|
tokio::io::split(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a channel is: the pipe `\\.\pipe\wanderbooks-<hash of the
|
||||||
|
/// root's run/ folder>-<name>` (INVARIANT 3).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Endpoint {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Endpoint {
|
||||||
|
/// The channel `name` for the root's `run/` folder `dir`.
|
||||||
|
pub fn new(dir: &Path, name: &str) -> io::Result<Endpoint> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
// The folder as the system names it (a short 8.3 name and its
|
||||||
|
// long one are one folder; every process must hash the same
|
||||||
|
// text), without the `\\?\` prefix canonicalizing adds; a
|
||||||
|
// folder name is not case-sensitive here.
|
||||||
|
let dir = std::fs::canonicalize(dir).or_else(|_| std::path::absolute(dir))?;
|
||||||
|
let text = dir.to_string_lossy().to_lowercase();
|
||||||
|
let key = text.strip_prefix(r"\\?\").unwrap_or(&text);
|
||||||
|
let digest = Sha256::digest(key.as_bytes());
|
||||||
|
let hash: String = digest[..8].iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
let name = name.strip_suffix(".sock").unwrap_or(name);
|
||||||
|
Ok(Endpoint {
|
||||||
|
name: format!(r"\\.\pipe\wanderbooks-{hash}-{name}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pipe's name.
|
||||||
|
pub fn pipe_name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make(name: &str, security: &winsec::OwnerOnly, first: bool) -> io::Result<NamedPipeServer> {
|
||||||
|
let mut options = ServerOptions::new();
|
||||||
|
options
|
||||||
|
.first_pipe_instance(first)
|
||||||
|
.reject_remote_clients(true);
|
||||||
|
// SAFETY: the pointer is to security attributes that `security`
|
||||||
|
// keeps alive for the call, which copies them.
|
||||||
|
unsafe { options.create_with_security_attributes_raw(name, security.as_raw()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A listener that is this account's alone (INVARIANT 1): a pool of
|
||||||
|
/// idle instances (INVARIANT 4), each handing its connection on when
|
||||||
|
/// a client comes.
|
||||||
|
pub struct Listener {
|
||||||
|
accepted: mpsc::UnboundedReceiver<io::Result<NamedPipeServer>>,
|
||||||
|
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Listener {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
for task in &self.tasks {
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Listener {
|
||||||
|
/// Make the pipe. Refused when another server already has it
|
||||||
|
/// (INVARIANT 3).
|
||||||
|
pub fn bind(endpoint: &Endpoint) -> io::Result<Listener> {
|
||||||
|
let security = Arc::new(winsec::OwnerOnly::new()?);
|
||||||
|
let first = make(&endpoint.name, &security, true)?;
|
||||||
|
let (tx, accepted) = mpsc::unbounded_channel();
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
let mut instance = Some(first);
|
||||||
|
for _ in 0..IDLE_INSTANCES {
|
||||||
|
let server = match instance.take() {
|
||||||
|
Some(server) => server,
|
||||||
|
None => make(&endpoint.name, &security, false)?,
|
||||||
|
};
|
||||||
|
tasks.push(tokio::spawn(wait_for_clients(
|
||||||
|
server,
|
||||||
|
endpoint.name.clone(),
|
||||||
|
security.clone(),
|
||||||
|
tx.clone(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Listener { accepted, tasks })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next connection, whoever it is from.
|
||||||
|
pub async fn accept(&mut self) -> io::Result<Accepted> {
|
||||||
|
match self.accepted.recv().await {
|
||||||
|
Some(result) => result,
|
||||||
|
None => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address of an accepted connection: none.
|
||||||
|
pub fn addr_of(_stream: &Accepted) -> io::Result<Addr> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address this listener is bound to: none.
|
||||||
|
pub fn local_addr(&self) -> io::Result<Addr> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One idle instance: wait for a client, put a fresh instance in its
|
||||||
|
/// place, and hand the connected one on.
|
||||||
|
async fn wait_for_clients(
|
||||||
|
mut server: NamedPipeServer,
|
||||||
|
name: String,
|
||||||
|
security: Arc<winsec::OwnerOnly>,
|
||||||
|
tx: mpsc::UnboundedSender<io::Result<NamedPipeServer>>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
if let Err(e) = server.connect().await {
|
||||||
|
// This instance is no good: say so, and put a fresh one in
|
||||||
|
// its place (nothing connected is handed on).
|
||||||
|
if tx.send(Err(e)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
if let Ok(fresh) = make(&name, &security, false) {
|
||||||
|
server = fresh;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let next = loop {
|
||||||
|
match make(&name, &security, false) {
|
||||||
|
Ok(next) => break next,
|
||||||
|
Err(e) => {
|
||||||
|
if tx.send(Err(e)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let connected = std::mem::replace(&mut server, next);
|
||||||
|
if tx.send(Ok(connected)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to `endpoint`, waiting a little while every instance is
|
||||||
|
/// busy (INVARIANT 4).
|
||||||
|
pub async fn connect(endpoint: &Endpoint) -> io::Result<Connected> {
|
||||||
|
let deadline = Instant::now() + BUSY_WAIT;
|
||||||
|
loop {
|
||||||
|
match ClientOptions::new()
|
||||||
|
.security_qos_flags(IDENTIFICATION)
|
||||||
|
.open(&endpoint.name)
|
||||||
|
{
|
||||||
|
Ok(client) => return Ok(client),
|
||||||
|
Err(e) if e.raw_os_error() == Some(PIPE_BUSY) && Instant::now() < deadline => {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connection this side made, for blocking code.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct StdStream {
|
||||||
|
file: std::fs::File,
|
||||||
|
read_timeout: Mutex<Option<Duration>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StdStream {
|
||||||
|
/// Give up reading after `timeout` (INVARIANT 5); `None` waits
|
||||||
|
/// for ever.
|
||||||
|
pub fn set_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
|
||||||
|
*self.read_timeout.lock().unwrap_or_else(|e| e.into_inner()) = timeout;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_some(&self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
let timeout = *self.read_timeout.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(timeout) = timeout {
|
||||||
|
// A blocking read can't be cut short, so it is made only
|
||||||
|
// when something is waiting.
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
match winsec::pipe_bytes_waiting(&self.file) {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(_) => break,
|
||||||
|
Err(e) if e.raw_os_error() == Some(BROKEN_PIPE) => return Ok(0),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(io::Error::from(io::ErrorKind::TimedOut));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match (&self.file).read(buf) {
|
||||||
|
Err(e) if e.raw_os_error() == Some(BROKEN_PIPE) => Ok(0),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
impl Read for StdStream {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
self.read_some(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Read for &StdStream {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
self.read_some(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for StdStream {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
(&self.file).write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
(&self.file).flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Write for &StdStream {
|
||||||
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||||
|
(&self.file).write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
(&self.file).flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to `endpoint`, for blocking code, waiting a little while
|
||||||
|
/// every instance is busy (INVARIANT 4).
|
||||||
|
pub fn connect_std(endpoint: &Endpoint) -> io::Result<StdStream> {
|
||||||
|
let deadline = Instant::now() + BUSY_WAIT;
|
||||||
|
loop {
|
||||||
|
match std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.security_qos_flags(IDENTIFICATION)
|
||||||
|
.open(&endpoint.name)
|
||||||
|
{
|
||||||
|
Ok(file) => {
|
||||||
|
return Ok(StdStream {
|
||||||
|
file,
|
||||||
|
read_timeout: Mutex::new(None),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) if e.raw_os_error() == Some(PIPE_BUSY) && Instant::now() < deadline => {
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Peer for NamedPipeServer {
|
||||||
|
fn peer_is_us(&self) -> bool {
|
||||||
|
self.peer_pid().is_some_and(winsec::pid_is_us)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer_pid(&self) -> Option<u32> {
|
||||||
|
winsec::pipe_client_pid(self).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Peer for NamedPipeClient {
|
||||||
|
fn peer_is_us(&self) -> bool {
|
||||||
|
self.peer_pid().is_some_and(winsec::pid_is_us)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer_pid(&self) -> Option<u32> {
|
||||||
|
winsec::pipe_server_pid(self).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Peer for StdStream {
|
||||||
|
fn peer_is_us(&self) -> bool {
|
||||||
|
self.peer_pid().is_some_and(winsec::pid_is_us)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer_pid(&self) -> Option<u32> {
|
||||||
|
winsec::pipe_server_pid(&self.file).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One stop signal.
|
||||||
|
pub enum RawStop {
|
||||||
|
/// Ctrl+C.
|
||||||
|
CtrlC(tokio::signal::windows::CtrlC),
|
||||||
|
/// Ctrl+Break.
|
||||||
|
CtrlBreak(tokio::signal::windows::CtrlBreak),
|
||||||
|
/// The console window is closed.
|
||||||
|
CtrlClose(tokio::signal::windows::CtrlClose),
|
||||||
|
/// The system is shutting down.
|
||||||
|
CtrlShutdown(tokio::signal::windows::CtrlShutdown),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawStop {
|
||||||
|
pub(super) async fn recv(&mut self) -> Option<()> {
|
||||||
|
match self {
|
||||||
|
RawStop::CtrlC(s) => s.recv().await,
|
||||||
|
RawStop::CtrlBreak(s) => s.recv().await,
|
||||||
|
RawStop::CtrlClose(s) => s.recv().await,
|
||||||
|
RawStop::CtrlShutdown(s) => s.recv().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn raw_stops() -> io::Result<Vec<RawStop>> {
|
||||||
|
use tokio::signal::windows;
|
||||||
|
Ok(vec![
|
||||||
|
RawStop::CtrlC(windows::ctrl_c()?),
|
||||||
|
RawStop::CtrlBreak(windows::ctrl_break()?),
|
||||||
|
RawStop::CtrlClose(windows::ctrl_close()?),
|
||||||
|
RawStop::CtrlShutdown(windows::ctrl_shutdown()?),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
fn endpoint(dir: &tempfile::TempDir) -> Endpoint {
|
||||||
|
Endpoint::new(dir.path(), "test.sock").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Both ends of a connection are this account, and name this process.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_listener_and_a_client_talk_and_each_knows_the_other_is_us() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let ep = endpoint(&dir);
|
||||||
|
let mut listener = Listener::bind(&ep).unwrap();
|
||||||
|
let client = connect(&ep).await.unwrap();
|
||||||
|
let server = listener.accept().await.unwrap();
|
||||||
|
assert!(peer_is_us(&server));
|
||||||
|
assert!(peer_is_us(&client));
|
||||||
|
#[cfg(any(windows, target_os = "linux"))]
|
||||||
|
{
|
||||||
|
assert_eq!(peer_pid(&server), Some(std::process::id()));
|
||||||
|
assert_eq!(peer_pid(&client), Some(std::process::id()));
|
||||||
|
}
|
||||||
|
let (read, mut write) = split_accepted(server);
|
||||||
|
let (cread, mut cwrite) = split_connected(client);
|
||||||
|
cwrite.write_all(b"hello\n").await.unwrap();
|
||||||
|
let mut line = String::new();
|
||||||
|
tokio::io::BufReader::new(read)
|
||||||
|
.read_line(&mut line)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(line, "hello\n");
|
||||||
|
write.write_all(b"back\n").await.unwrap();
|
||||||
|
let mut line = String::new();
|
||||||
|
tokio::io::BufReader::new(cread)
|
||||||
|
.read_line(&mut line)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(line, "back\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blocking client gives up at its limit, and reads the end of the
|
||||||
|
/// stream once the server has gone (INVARIANT 5).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_blocking_client_times_out_and_reads_the_end_when_the_server_goes() {
|
||||||
|
use std::io::Read;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let ep = endpoint(&dir);
|
||||||
|
let mut listener = Listener::bind(&ep).unwrap();
|
||||||
|
let ep2 = ep_for(&dir);
|
||||||
|
let client = tokio::task::spawn_blocking(move || connect_std(&ep2).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let server = listener.accept().await.unwrap();
|
||||||
|
assert!(peer_is_us(&client));
|
||||||
|
client
|
||||||
|
.set_read_timeout(Some(Duration::from_millis(100)))
|
||||||
|
.unwrap();
|
||||||
|
let (client, kind) = tokio::task::spawn_blocking(move || {
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
let kind = (&client).read(&mut byte).unwrap_err().kind();
|
||||||
|
(client, kind)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
kind,
|
||||||
|
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
|
||||||
|
),
|
||||||
|
"{kind:?}"
|
||||||
|
);
|
||||||
|
drop(server);
|
||||||
|
let n = tokio::task::spawn_blocking(move || {
|
||||||
|
let mut byte = [0u8; 1];
|
||||||
|
(&client).read(&mut byte).unwrap()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(n, 0, "the end of the stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ep_for(dir: &tempfile::TempDir) -> Endpoint {
|
||||||
|
Endpoint::new(dir.path(), "test.sock").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Many clients at once all get in (INVARIANT 4).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn many_clients_at_once_all_connect() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let ep = endpoint(&dir);
|
||||||
|
let mut listener = Listener::bind(&ep).unwrap();
|
||||||
|
let ep = std::sync::Arc::new(ep);
|
||||||
|
let mut clients = Vec::new();
|
||||||
|
for _ in 0..48 {
|
||||||
|
let ep = ep.clone();
|
||||||
|
clients.push(tokio::spawn(async move { connect(&ep).await }));
|
||||||
|
}
|
||||||
|
let mut accepted = Vec::new();
|
||||||
|
for _ in 0..48 {
|
||||||
|
accepted.push(
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), listener.accept())
|
||||||
|
.await
|
||||||
|
.expect("accepted in time")
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for c in clients {
|
||||||
|
c.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing listening: not found, so callers say "not running".
|
||||||
|
#[tokio::test]
|
||||||
|
async fn nothing_listening_is_not_found() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let err = connect(&endpoint(&dir)).await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err.kind(),
|
||||||
|
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused
|
||||||
|
),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two roots on one machine never share a pipe (INVARIANT 3), and a
|
||||||
|
/// second server cannot take the first one's pipe.
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_pipe_is_per_root_and_cannot_be_taken_twice() {
|
||||||
|
let (a, b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||||
|
assert_ne!(endpoint(&a).pipe_name(), endpoint(&b).pipe_name());
|
||||||
|
assert!(
|
||||||
|
endpoint(&a)
|
||||||
|
.pipe_name()
|
||||||
|
.starts_with(r"\\.\pipe\wanderbooks-")
|
||||||
|
);
|
||||||
|
let _first = Listener::bind(&endpoint(&a)).unwrap();
|
||||||
|
assert!(Listener::bind(&endpoint(&a)).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ pub mod access;
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
mod handbook;
|
mod handbook;
|
||||||
#[cfg(unix)]
|
pub mod ipc;
|
||||||
pub mod local;
|
pub mod local;
|
||||||
pub mod opening;
|
pub mod opening;
|
||||||
mod policy;
|
mod policy;
|
||||||
|
|||||||
+41
-128
@@ -60,6 +60,7 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::admin::{self, Request, Response};
|
use crate::admin::{self, Request, Response};
|
||||||
|
use crate::ipc::{self, Peer};
|
||||||
use crate::server::{BooksServer, Isolation, Served, SharedStore};
|
use crate::server::{BooksServer, Isolation, Served, SharedStore};
|
||||||
use ledger_store::Store;
|
use ledger_store::Store;
|
||||||
use ledger_store::access::{Access, AccessLog, Outcome, Principal};
|
use ledger_store::access::{Access, AccessLog, Outcome, Principal};
|
||||||
@@ -69,7 +70,6 @@ use rmcp::transport::streamable_http_server::{
|
|||||||
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
||||||
};
|
};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::os::fd::AsRawFd;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
@@ -994,110 +994,25 @@ fn moved_in_at_adoption(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user ID at the other end of a Unix socket.
|
|
||||||
pub fn peer_uid(fd: &impl AsRawFd) -> std::io::Result<u32> {
|
|
||||||
let fd = fd.as_raw_fd();
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
|
||||||
{
|
|
||||||
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 for writes of the sizes given.
|
|
||||||
let rc = unsafe {
|
|
||||||
libc::getsockopt(
|
|
||||||
fd,
|
|
||||||
libc::SOL_SOCKET,
|
|
||||||
libc::SO_PEERCRED,
|
|
||||||
(&mut cred as *mut libc::ucred).cast(),
|
|
||||||
&mut len,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if rc != 0 {
|
|
||||||
return Err(std::io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
if len as usize != std::mem::size_of::<libc::ucred>() {
|
|
||||||
return Err(std::io::Error::other(
|
|
||||||
"the peer's credentials came back short",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(cred.uid)
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
|
||||||
{
|
|
||||||
let mut uid: libc::uid_t = 0;
|
|
||||||
let mut gid: libc::gid_t = 0;
|
|
||||||
// SAFETY: `uid` and `gid` are valid for writes.
|
|
||||||
if unsafe { libc::getpeereid(fd, &mut uid, &mut gid) } != 0 {
|
|
||||||
return Err(std::io::Error::last_os_error());
|
|
||||||
}
|
|
||||||
Ok(uid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The process at the other end of a Unix socket, where the system says
|
|
||||||
/// (Linux); `None` elsewhere or if it can't be read.
|
|
||||||
pub fn peer_pid(fd: &impl AsRawFd) -> Option<u32> {
|
|
||||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
|
||||||
{
|
|
||||||
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 for writes of the sizes given.
|
|
||||||
let rc = unsafe {
|
|
||||||
libc::getsockopt(
|
|
||||||
fd.as_raw_fd(),
|
|
||||||
libc::SOL_SOCKET,
|
|
||||||
libc::SO_PEERCRED,
|
|
||||||
(&mut cred as *mut libc::ucred).cast(),
|
|
||||||
&mut len,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
(rc == 0 && len as usize == std::mem::size_of::<libc::ucred>() && cred.pid > 0)
|
|
||||||
.then_some(cred.pid as u32)
|
|
||||||
}
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
|
||||||
{
|
|
||||||
let _ = fd;
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This process's user ID.
|
|
||||||
fn own_uid() -> u32 {
|
|
||||||
// SAFETY: geteuid has no preconditions and can't fail.
|
|
||||||
unsafe { libc::geteuid() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the other end of a socket is this account (INVARIANT 2).
|
|
||||||
pub fn peer_is_us(fd: &impl AsRawFd) -> bool {
|
|
||||||
peer_uid(fd).is_ok_and(|uid| uid == own_uid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The client's check of the server (plan 3.9: both ends check).
|
/// The client's check of the server (plan 3.9: both ends check).
|
||||||
pub(crate) fn check_peer_std(stream: &std::os::unix::net::UnixStream) -> Result<(), String> {
|
pub(crate) fn check_peer_std(stream: &ipc::StdStream) -> Result<(), String> {
|
||||||
if peer_is_us(stream) {
|
if stream.peer_is_us() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err("the books server's socket belongs to another account: not used".into())
|
Err("the books server's socket belongs to another account: not used".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Unix listener that hands on only this account's connections
|
/// A listener that hands on only this account's connections
|
||||||
/// (INVARIANT 2).
|
/// (INVARIANT 2).
|
||||||
struct OwnerOnly(tokio::net::UnixListener);
|
struct OwnerOnly(ipc::Listener);
|
||||||
|
|
||||||
impl OwnerOnly {
|
impl OwnerOnly {
|
||||||
async fn next(&mut self) -> tokio::net::UnixStream {
|
async fn next(&mut self) -> ipc::Accepted {
|
||||||
loop {
|
loop {
|
||||||
match self.0.accept().await {
|
match self.0.accept().await {
|
||||||
Ok((stream, _)) if peer_is_us(&stream) => return stream,
|
Ok(stream) if stream.peer_is_us() => return stream,
|
||||||
Ok((stream, _)) => {
|
Ok(stream) => {
|
||||||
eprintln!("wanderbooks server: refused a connection from another account");
|
eprintln!("wanderbooks server: refused a connection from another account");
|
||||||
drop(stream);
|
drop(stream);
|
||||||
}
|
}
|
||||||
@@ -1120,13 +1035,13 @@ impl OwnerOnly {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl axum::serve::Listener for OwnerOnly {
|
impl axum::serve::Listener for OwnerOnly {
|
||||||
type Io = tokio::net::UnixStream;
|
type Io = ipc::Accepted;
|
||||||
type Addr = tokio::net::unix::SocketAddr;
|
type Addr = ipc::Addr;
|
||||||
|
|
||||||
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
|
async fn accept(&mut self) -> (Self::Io, Self::Addr) {
|
||||||
loop {
|
loop {
|
||||||
let stream = self.next().await;
|
let stream = self.next().await;
|
||||||
if let Ok(addr) = stream.peer_addr() {
|
if let Ok(addr) = ipc::Listener::addr_of(&stream) {
|
||||||
return (stream, addr);
|
return (stream, addr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1821,9 +1736,9 @@ async fn not_found() -> (axum::http::StatusCode, &'static str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One admin connection: one request, one answer (admin INVARIANT 1).
|
/// One admin connection: one request, one answer (admin INVARIANT 1).
|
||||||
async fn serve_admin(stream: tokio::net::UnixStream, books: Arc<Books>) {
|
async fn serve_admin(stream: ipc::Accepted, books: Arc<Books>) {
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
||||||
let (read, mut write) = stream.into_split();
|
let (read, mut write) = ipc::split_accepted(stream);
|
||||||
let mut reader = tokio::io::BufReader::new(read.take(admin::MAX_LINE as u64 + 1));
|
let mut reader = tokio::io::BufReader::new(read.take(admin::MAX_LINE as u64 + 1));
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
// A request still arriving when closing begins is turned away.
|
// A request still arriving when closing begins is turned away.
|
||||||
@@ -1872,18 +1787,8 @@ async fn serve_admin(stream: tokio::net::UnixStream, books: Arc<Books>) {
|
|||||||
|
|
||||||
/// Bind a socket in `run/`, replacing a stale one (INVARIANT 1: only the
|
/// Bind a socket in `run/`, replacing a stale one (INVARIANT 1: only the
|
||||||
/// server holding the lock gets here).
|
/// server holding the lock gets here).
|
||||||
fn bind(run: &Path, name: &str) -> std::io::Result<tokio::net::UnixListener> {
|
fn bind(run: &Path, name: &str) -> std::io::Result<ipc::Listener> {
|
||||||
let path = run.join(name);
|
ipc::Listener::bind(&ipc::Endpoint::new(run, name)?)
|
||||||
match std::fs::remove_file(&path) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
}
|
|
||||||
let short = ledger_store::root::SocketPath::new(run, name)?;
|
|
||||||
let listener = tokio::net::UnixListener::bind(short.path())?;
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
|
||||||
Ok(listener)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The address `--http` takes: loopback only (plan 3.9, P9a).
|
/// The address `--http` takes: loopback only (plan 3.9, P9a).
|
||||||
@@ -2028,8 +1933,14 @@ pub fn run(
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
// Only once it is ours: a refused server touches nothing.
|
// Only once it is ours: a refused server touches nothing.
|
||||||
use std::os::unix::fs::PermissionsExt;
|
#[cfg(unix)]
|
||||||
std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600))
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600))
|
||||||
|
.map_err(|e| format!("can't make the server lock private: {}", e.kind()))?;
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
ledger_store::winsec::set_owner_only(&lock_path)
|
||||||
.map_err(|e| format!("can't make the server lock private: {}", e.kind()))?;
|
.map_err(|e| format!("can't make the server lock private: {}", e.kind()))?;
|
||||||
}
|
}
|
||||||
let books = Arc::new(Books {
|
let books = Arc::new(Books {
|
||||||
@@ -2295,7 +2206,6 @@ async fn serve(
|
|||||||
/// SIGTERM, SIGINT or SIGHUP; hurry on the next signal. Runs inside the
|
/// SIGTERM, SIGINT or SIGHUP; hurry on the next signal. Runs inside the
|
||||||
/// runtime, before the books are opened.
|
/// runtime, before the books are opened.
|
||||||
fn watch_for_close(stops: &Stops) -> std::io::Result<()> {
|
fn watch_for_close(stops: &Stops) -> std::io::Result<()> {
|
||||||
use tokio::signal::unix::{SignalKind, signal};
|
|
||||||
let on_eof = stops.close.clone();
|
let on_eof = stops.close.clone();
|
||||||
// A plain thread: a blocked read must never hold up the runtime's end.
|
// A plain thread: a blocked read must never hold up the runtime's end.
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
@@ -2314,12 +2224,7 @@ fn watch_for_close(stops: &Stops) -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
on_eof.cancel();
|
on_eof.cancel();
|
||||||
})?;
|
})?;
|
||||||
for kind in [
|
for mut signals in ipc::stop_signals()? {
|
||||||
SignalKind::terminate(),
|
|
||||||
SignalKind::interrupt(),
|
|
||||||
SignalKind::hangup(),
|
|
||||||
] {
|
|
||||||
let mut signals = signal(kind)?;
|
|
||||||
let (close, hurry) = (stops.close.clone(), stops.hurry.clone());
|
let (close, hurry) = (stops.close.clone(), stops.hurry.clone());
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while signals.recv().await.is_some() {
|
while signals.recv().await.is_some() {
|
||||||
@@ -2616,29 +2521,32 @@ mod tests {
|
|||||||
assert_eq!(backup_due(Some(&w), 5, at(9 + retry * 3), quiet), None);
|
assert_eq!(backup_due(Some(&w), 5, at(9 + retry * 3), quiet), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn a_socket_pair_is_this_account_at_both_ends() {
|
fn a_socket_pair_is_this_account_at_both_ends() {
|
||||||
let (a, b) = std::os::unix::net::UnixStream::pair().unwrap();
|
let (a, b) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||||
assert_eq!(peer_uid(&a).unwrap(), own_uid());
|
assert_eq!(ipc::peer_uid(&a).unwrap(), ipc::own_uid());
|
||||||
assert!(peer_is_us(&b));
|
assert!(ipc::peer_is_us(&b));
|
||||||
assert!(check_peer_std(&a).is_ok());
|
assert!(check_peer_std(&a).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn a_socket_pair_names_this_process() {
|
fn a_socket_pair_names_this_process() {
|
||||||
let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
|
let (a, _b) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||||
if cfg!(target_os = "linux") {
|
if cfg!(target_os = "linux") {
|
||||||
assert_eq!(peer_pid(&a), Some(std::process::id()));
|
assert_eq!(ipc::peer_pid(&a), Some(std::process::id()));
|
||||||
}
|
}
|
||||||
let file = std::fs::File::open("/dev/null").unwrap();
|
let file = std::fs::File::open("/dev/null").unwrap();
|
||||||
assert_eq!(peer_pid(&file), None);
|
assert_eq!(ipc::peer_pid(&file), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn a_descriptor_that_isnt_a_socket_is_never_taken_for_us() {
|
fn a_descriptor_that_isnt_a_socket_is_never_taken_for_us() {
|
||||||
let file = std::fs::File::open("/dev/null").unwrap();
|
let file = std::fs::File::open("/dev/null").unwrap();
|
||||||
assert!(peer_uid(&file).is_err());
|
assert!(ipc::peer_uid(&file).is_err());
|
||||||
assert!(!peer_is_us(&file));
|
assert!(!ipc::peer_is_us(&file));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A socket with no other end: the system answers for it (Linux: no
|
/// A socket with no other end: the system answers for it (Linux: no
|
||||||
@@ -2655,8 +2563,8 @@ mod tests {
|
|||||||
assert!(fd >= 0);
|
assert!(fd >= 0);
|
||||||
// SAFETY: a fresh descriptor of a Unix stream socket, owned by none.
|
// SAFETY: a fresh descriptor of a Unix stream socket, owned by none.
|
||||||
let lone = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd) };
|
let lone = unsafe { std::os::unix::net::UnixStream::from_raw_fd(fd) };
|
||||||
assert_eq!(peer_pid(&lone), None);
|
assert_eq!(ipc::peer_pid(&lone), None);
|
||||||
assert!(!peer_is_us(&lone));
|
assert!(!ipc::peer_is_us(&lone));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
check_peer_std(&lone).unwrap_err(),
|
check_peer_std(&lone).unwrap_err(),
|
||||||
"the books server's socket belongs to another account: not used"
|
"the books server's socket belongs to another account: not used"
|
||||||
@@ -3201,6 +3109,7 @@ mod tests {
|
|||||||
let id = BookId::parse(id).unwrap();
|
let id = BookId::parse(id).unwrap();
|
||||||
let dir = books.root.book_dir(&id);
|
let dir = books.root.book_dir(&id);
|
||||||
std::fs::create_dir(&dir).unwrap();
|
std::fs::create_dir(&dir).unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
std::fs::set_permissions(&dir, std::os::unix::fs::PermissionsExt::from_mode(0o700))
|
std::fs::set_permissions(&dir, std::os::unix::fs::PermissionsExt::from_mode(0o700))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut s =
|
let mut s =
|
||||||
@@ -3262,7 +3171,11 @@ mod tests {
|
|||||||
/// git, on the books' own archive: what it said.
|
/// git, on the books' own archive: what it said.
|
||||||
fn archive_git(dir: &Path, args: &[&str]) -> String {
|
fn archive_git(dir: &Path, args: &[&str]) -> String {
|
||||||
let out = std::process::Command::new("git")
|
let out = std::process::Command::new("git")
|
||||||
.arg(format!("--git-dir={}", dir.join("archive.git").display()))
|
.arg(format!(
|
||||||
|
"--git-dir={}",
|
||||||
|
// A canonical path on Windows carries `\\?\`, which git refuses.
|
||||||
|
ledger_store::root::plain_path(&dir.join("archive.git")).display()
|
||||||
|
))
|
||||||
.args(args)
|
.args(args)
|
||||||
.output()
|
.output()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1159,7 +1159,9 @@ fn logged(tag: &str) -> (BooksServer, TestDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Books whose access log can't be written: its file is a FIFO no one
|
/// Books whose access log can't be written: its file is a FIFO no one
|
||||||
/// reads, so every record fails (as on a full disk).
|
/// reads, so every record fails (as on a full disk). Unix only: there is
|
||||||
|
/// no FIFO in a file's place on Windows.
|
||||||
|
#[cfg(unix)]
|
||||||
fn unwritable(tag: &str) -> (BooksServer, TestDir) {
|
fn unwritable(tag: &str) -> (BooksServer, TestDir) {
|
||||||
let books = unit_dir(tag);
|
let books = unit_dir(tag);
|
||||||
let store = store(&books);
|
let store = store(&books);
|
||||||
@@ -1430,7 +1432,9 @@ fn a_call_the_books_close_or_stop_under_is_refused_as_not_run() {
|
|||||||
/// access.rs INVARIANT 3: a record that can't be written stops the
|
/// access.rs INVARIANT 3: a record that can't be written stops the
|
||||||
/// books, and the answer says so; a call or read refused as not run
|
/// books, and the answer says so; a call or read refused as not run
|
||||||
/// (closing) keeps its not-run code, so the bridge still sends it
|
/// (closing) keeps its not-run code, so the bridge still sends it
|
||||||
/// again; anything else is an error in words.
|
/// again; anything else is an error in words. Unix only: its fixture is a
|
||||||
|
/// FIFO (the refusal path itself is the same on every system).
|
||||||
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn an_access_record_that_cant_be_written_stops_the_books_and_says_so() {
|
fn an_access_record_that_cant_be_written_stops_the_books_and_says_so() {
|
||||||
rt().block_on(async {
|
rt().block_on(async {
|
||||||
|
|||||||
@@ -28,6 +28,22 @@ argon2 = "0.6"
|
|||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
|
# Owner-only DACLs, the account's SID, the known-folder API and
|
||||||
|
# MoveFileExW (P11c, D-62). The raw bindings, no runtime: the smallest
|
||||||
|
# Microsoft crate that covers them, already in the lockfile.
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-sys = { version = "0.61", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_Security_Authorization",
|
||||||
|
"Win32_Storage_FileSystem",
|
||||||
|
"Win32_System_Com",
|
||||||
|
"Win32_System_Pipes",
|
||||||
|
"Win32_System_SystemServices",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_UI_Shell",
|
||||||
|
] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
|||||||
@@ -124,11 +124,20 @@ impl Git {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
dirs.iter()
|
dirs.iter()
|
||||||
.chain(
|
.chain(
|
||||||
["/usr/bin", "/bin", "/usr/local/bin", "/opt/homebrew/bin"]
|
[
|
||||||
.iter()
|
"/usr/bin",
|
||||||
.map(PathBuf::from)
|
"/bin",
|
||||||
.collect::<Vec<_>>()
|
"/usr/local/bin",
|
||||||
.iter(),
|
"/opt/homebrew/bin",
|
||||||
|
// Git for Windows' own folders (a host started clean
|
||||||
|
// has a short PATH).
|
||||||
|
r"C:\Program Files\Git\cmd",
|
||||||
|
r"C:\Program Files\Git\bin",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.iter(),
|
||||||
)
|
)
|
||||||
.filter(|d| d.is_absolute())
|
.filter(|d| d.is_absolute())
|
||||||
.map(|d| d.join(if cfg!(windows) { "git.exe" } else { "git" }))
|
.map(|d| d.join(if cfg!(windows) { "git.exe" } else { "git" }))
|
||||||
@@ -214,7 +223,13 @@ impl Git {
|
|||||||
fn run(&self, what: &str, args: &[&std::ffi::OsStr]) -> Result<String, BackupError> {
|
fn run(&self, what: &str, args: &[&std::ffi::OsStr]) -> Result<String, BackupError> {
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
let mut c = self.command();
|
let mut c = self.command();
|
||||||
c.args(args);
|
// A path made canonical on Windows carries `\\?\`, which git refuses.
|
||||||
|
c.args(args.iter().map(|a| match a.to_str() {
|
||||||
|
Some(text) if text.contains(r"\\?\") => {
|
||||||
|
std::ffi::OsString::from(crate::root::without_extended_prefix(text))
|
||||||
|
}
|
||||||
|
_ => (*a).to_os_string(),
|
||||||
|
}));
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
@@ -266,8 +281,24 @@ impl Git {
|
|||||||
let kill_group = |pid: u32| unsafe {
|
let kill_group = |pid: u32| unsafe {
|
||||||
libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
|
libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
|
||||||
};
|
};
|
||||||
#[cfg(not(unix))]
|
// Windows has no process group to signal: a git that has gone quiet
|
||||||
let kill_group = |_: u32| {};
|
// is stopped with the tree of processes it started (`taskkill /T`).
|
||||||
|
// Once git has ended normally nothing is looked for (a process
|
||||||
|
// spawned per git call would cost more than the leftovers it could
|
||||||
|
// find).
|
||||||
|
#[cfg(windows)]
|
||||||
|
let kill_group = |pid: u32| {
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
};
|
||||||
|
#[cfg(unix)]
|
||||||
|
let kill_leftovers = kill_group;
|
||||||
|
#[cfg(windows)]
|
||||||
|
let kill_leftovers = |_: u32| {};
|
||||||
let status = loop {
|
let status = loop {
|
||||||
if let Some(status) = child.try_wait()? {
|
if let Some(status) = child.try_wait()? {
|
||||||
break status;
|
break status;
|
||||||
@@ -284,7 +315,7 @@ impl Git {
|
|||||||
std::thread::sleep(Duration::from_millis(20));
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
};
|
};
|
||||||
// What it started may still hold its pipes: not waited for long.
|
// What it started may still hold its pipes: not waited for long.
|
||||||
kill_group(child.id());
|
kill_leftovers(child.id());
|
||||||
let wait = Duration::from_secs(10);
|
let wait = Duration::from_secs(10);
|
||||||
let stdout = stdout.recv_timeout(wait).unwrap_or_default();
|
let stdout = stdout.recv_timeout(wait).unwrap_or_default();
|
||||||
let stderr = stderr.recv_timeout(wait).unwrap_or_default();
|
let stderr = stderr.recv_timeout(wait).unwrap_or_default();
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ impl ChainLog {
|
|||||||
genesis_text: &str,
|
genesis_text: &str,
|
||||||
) -> Result<(Self, Vec<Map<String, Value>>), ChainError> {
|
) -> Result<(Self, Vec<Map<String, Value>>), ChainError> {
|
||||||
let read = read(path, genesis_text)?;
|
let read = read(path, genesis_text)?;
|
||||||
|
#[cfg(unix)]
|
||||||
let new = !path.exists();
|
let new = !path.exists();
|
||||||
let mut options = OpenOptions::new();
|
let mut options = OpenOptions::new();
|
||||||
options.create(true).append(true);
|
options.create(true).append(true);
|
||||||
@@ -205,9 +206,12 @@ impl ChainLog {
|
|||||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
if !crate::winsec::is_private(path).unwrap_or(false) {
|
||||||
|
crate::winsec::set_owner_only(path)?;
|
||||||
|
}
|
||||||
if read.torn {
|
if read.torn {
|
||||||
file.set_len(read.good_len)?;
|
truncate(&file, path, read.good_len)?;
|
||||||
file.sync_all()?;
|
|
||||||
}
|
}
|
||||||
// A log just made is in its folder for good (its entry synced).
|
// A log just made is in its folder for good (its entry synced).
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -275,8 +279,7 @@ impl ChainLog {
|
|||||||
.and_then(|()| self.file.sync_data());
|
.and_then(|()| self.file.sync_data());
|
||||||
if let Err(e) = written {
|
if let Err(e) = written {
|
||||||
self.broken = true;
|
self.broken = true;
|
||||||
let _ = self.file.set_len(before);
|
let _ = truncate(&self.file, &self.path, before);
|
||||||
let _ = self.file.sync_all();
|
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
self.head = Head { seq, hash };
|
self.head = Head { seq, hash };
|
||||||
@@ -284,6 +287,28 @@ impl ChainLog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cut the log `path` (open as `file`) to `len` bytes and sync it. On
|
||||||
|
/// Windows an append-only handle (which is what `append(true)` opens) has
|
||||||
|
/// no right to truncate, so a short-lived handle with write access does it;
|
||||||
|
/// the log is opened with the sharing std gives every handle (read, write
|
||||||
|
/// and delete), so both can be open at once.
|
||||||
|
pub(crate) fn truncate(file: &File, path: &Path, len: u64) -> std::io::Result<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let cutter = OpenOptions::new().write(true).open(path)?;
|
||||||
|
cutter.set_len(len)?;
|
||||||
|
cutter.sync_all()?;
|
||||||
|
let _ = file;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let _ = path;
|
||||||
|
file.set_len(len)?;
|
||||||
|
file.sync_all()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -228,8 +228,7 @@ impl JournalWriter {
|
|||||||
.and_then(|()| self.file.write_all(b"\n"))
|
.and_then(|()| self.file.write_all(b"\n"))
|
||||||
.and_then(|()| self.file.sync_all());
|
.and_then(|()| self.file.sync_all());
|
||||||
if let Err(e) = written {
|
if let Err(e) = written {
|
||||||
let _ = self.file.set_len(before);
|
let _ = crate::chain::truncate(&self.file, &self.path, before);
|
||||||
let _ = self.file.sync_all();
|
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
self.prev_hash = sha256_hex(bytes.as_bytes());
|
self.prev_hash = sha256_hex(bytes.as_bytes());
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub mod exports;
|
|||||||
pub mod form1099;
|
pub mod form1099;
|
||||||
pub mod format;
|
pub mod format;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
|
pub mod lock;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod ops;
|
pub mod ops;
|
||||||
pub mod passphrase;
|
pub mod passphrase;
|
||||||
@@ -41,6 +42,8 @@ pub mod schema;
|
|||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod uploads;
|
pub mod uploads;
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub mod winsec;
|
||||||
|
|
||||||
pub use blob::BlobStore;
|
pub use blob::BlobStore;
|
||||||
pub use error::StoreError;
|
pub use error::StoreError;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//! File locks, behind one seam (P11c, D-62).
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! GOAL: "some process holds this file" is asked and answered the same
|
||||||
|
//! way on every system.
|
||||||
|
//! SOURCE: the standard library's `File::try_lock` and `try_lock_shared`
|
||||||
|
//! (flock(2) on Unix, LockFileEx on Windows), plan/served.md 3.9
|
||||||
|
//! ("the viewer's presence is a shared lock").
|
||||||
|
//! INVARIANTS:
|
||||||
|
//! 1. Only the standard library's locks are used: no second copy of an
|
||||||
|
//! `unsafe` flock or LockFileEx call lives anywhere else.
|
||||||
|
//! 2. A lock is let go when its file is closed, by this process ending
|
||||||
|
//! or not (the system does it; nothing here keeps a record).
|
||||||
|
//! 3. A question that cannot be answered is answered "not held".
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::fs::{File, TryLockError};
|
||||||
|
|
||||||
|
/// Take a shared lock on `file` without waiting: whether it is now held.
|
||||||
|
pub fn try_shared(file: &File) -> bool {
|
||||||
|
file.try_lock_shared().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether another holder keeps a shared lock on `file` (so that an
|
||||||
|
/// exclusive one can't be taken now). A lock taken to find out is let go
|
||||||
|
/// at once.
|
||||||
|
pub fn held_elsewhere(file: &File) -> bool {
|
||||||
|
match file.try_lock() {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = file.unlock();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(TryLockError::WouldBlock) => true,
|
||||||
|
Err(TryLockError::Error(_)) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,13 +238,8 @@ impl Presence {
|
|||||||
/// (None until it has: the viewer makes nothing).
|
/// (None until it has: the viewer makes nothing).
|
||||||
pub fn hold(data_dir: &Path) -> Option<Presence> {
|
pub fn hold(data_dir: &Path) -> Option<Presence> {
|
||||||
let f = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)).ok()?;
|
let f = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)).ok()?;
|
||||||
#[cfg(unix)]
|
if !crate::lock::try_shared(&f) {
|
||||||
{
|
return None;
|
||||||
use std::os::fd::AsRawFd;
|
|
||||||
// SAFETY: flock on a descriptor this function owns.
|
|
||||||
if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } != 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some(Presence(f))
|
Some(Presence(f))
|
||||||
}
|
}
|
||||||
@@ -256,23 +251,7 @@ pub fn viewer_open(data_dir: &Path) -> bool {
|
|||||||
let Ok(f) = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)) else {
|
let Ok(f) = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
#[cfg(unix)]
|
crate::lock::held_elsewhere(&f)
|
||||||
{
|
|
||||||
use std::os::fd::AsRawFd;
|
|
||||||
// SAFETY: flock on a descriptor this function owns; a lock taken
|
|
||||||
// is let go at once (and with the descriptor anyway).
|
|
||||||
let free = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0;
|
|
||||||
if free {
|
|
||||||
// SAFETY: as above.
|
|
||||||
unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_UN) };
|
|
||||||
}
|
|
||||||
!free
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
drop(f);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -308,6 +287,23 @@ mod tests {
|
|||||||
assert_eq!(requests(dir.path()).len(), KEPT);
|
assert_eq!(requests(dir.path()).len(), KEPT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The lock seam on every system: a viewer's presence is seen by
|
||||||
|
/// another handle, shared between viewers, and let go with its file.
|
||||||
|
#[test]
|
||||||
|
fn a_viewer_s_presence_is_a_shared_lock_others_can_see() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert!(Presence::hold(dir.path()).is_none());
|
||||||
|
prepare(dir.path()).unwrap();
|
||||||
|
assert!(!viewer_open(dir.path()));
|
||||||
|
let held = Presence::hold(dir.path()).unwrap();
|
||||||
|
assert!(viewer_open(dir.path()));
|
||||||
|
let second = Presence::hold(dir.path()).unwrap();
|
||||||
|
drop(held);
|
||||||
|
assert!(viewer_open(dir.path()));
|
||||||
|
drop(second);
|
||||||
|
assert!(!viewer_open(dir.path()));
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn the_server_makes_the_folder_and_the_viewer_only_reads_it() {
|
fn the_server_makes_the_folder_and_the_viewer_only_reads_it() {
|
||||||
|
|||||||
+113
-27
@@ -179,8 +179,8 @@ impl std::fmt::Display for BookId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// This account's home folder, from the password database on Unix (never
|
/// This account's home folder, from the password database on Unix (never
|
||||||
/// `$HOME`, which the process that started us chose), or the Windows
|
/// `$HOME`, which the process that started us chose), or the profile
|
||||||
/// profile.
|
/// folder the Windows shell reports.
|
||||||
pub fn account_home() -> Result<PathBuf, RootError> {
|
pub fn account_home() -> Result<PathBuf, RootError> {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -190,11 +190,9 @@ pub fn account_home() -> Result<PathBuf, RootError> {
|
|||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
// Read from the environment until the Windows build (P11) takes it
|
// The known-folder API, never `%USERPROFILE%`, which the process
|
||||||
// from the known-folder API.
|
// that started us chose (the Unix arm's rule).
|
||||||
std::env::var_os("USERPROFILE")
|
crate::winsec::profile_folder().ok_or(RootError::NoHome)
|
||||||
.map(PathBuf::from)
|
|
||||||
.ok_or(RootError::NoHome)
|
|
||||||
}
|
}
|
||||||
#[cfg(not(any(unix, windows)))]
|
#[cfg(not(any(unix, windows)))]
|
||||||
{
|
{
|
||||||
@@ -256,6 +254,15 @@ fn unix_account() -> Option<(PathBuf, Vec<u8>)> {
|
|||||||
|
|
||||||
/// The platform's data folder under `home`: where the default root sits.
|
/// The platform's data folder under `home`: where the default root sits.
|
||||||
pub fn platform_data_dir(home: &Path) -> PathBuf {
|
pub fn platform_data_dir(home: &Path) -> PathBuf {
|
||||||
|
// This account's own home on Windows: `%LOCALAPPDATA%` as the shell
|
||||||
|
// reports it (a redirected profile included). Any other home (tests)
|
||||||
|
// gets the default place under it.
|
||||||
|
#[cfg(windows)]
|
||||||
|
if account_home().is_ok_and(|own| own == home)
|
||||||
|
&& let Some(local) = crate::winsec::local_app_data()
|
||||||
|
{
|
||||||
|
return local;
|
||||||
|
}
|
||||||
if cfg!(target_os = "macos") {
|
if cfg!(target_os = "macos") {
|
||||||
home.join("Library").join("Application Support")
|
home.join("Library").join("Application Support")
|
||||||
} else if cfg!(windows) {
|
} else if cfg!(windows) {
|
||||||
@@ -283,7 +290,7 @@ pub fn inside_a_root(path: &Path) -> bool {
|
|||||||
let Some(existing) = path.ancestors().find(|dir| dir.exists()) else {
|
let Some(existing) = path.ancestors().find(|dir| dir.exists()) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let real = std::fs::canonicalize(existing).unwrap_or_else(|_| existing.to_path_buf());
|
let real = canonical(existing).unwrap_or_else(|_| existing.to_path_buf());
|
||||||
real.ancestors().any(|dir| dir.join(MARKER_FILE).is_file())
|
real.ancestors().any(|dir| dir.join(MARKER_FILE).is_file())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +302,7 @@ pub fn private_book_folder(book_dir: &Path, name: &str) -> Result<PathBuf, RootE
|
|||||||
let dir = book_dir.join(name);
|
let dir = book_dir.join(name);
|
||||||
match std::fs::symlink_metadata(&dir) {
|
match std::fs::symlink_metadata(&dir) {
|
||||||
Ok(meta) => {
|
Ok(meta) => {
|
||||||
let open = check_private_dir(&meta, true, "a folder inside the books")?;
|
let open = check_private_dir(&dir, &meta, true, "a folder inside the books")?;
|
||||||
tighten_if(&dir, open)?;
|
tighten_if(&dir, open)?;
|
||||||
}
|
}
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
@@ -307,6 +314,30 @@ pub fn private_book_folder(book_dir: &Path, name: &str) -> Result<PathBuf, RootE
|
|||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `text` with Windows' extended-length prefix taken off the paths in it:
|
||||||
|
/// `\\?\C:\dir` becomes `C:\dir` and `\\?\UNC\server\share` becomes
|
||||||
|
/// `\\server\share`. `std::fs::canonicalize` returns that form on Windows,
|
||||||
|
/// and programs such as git refuse it. Text without the prefix is
|
||||||
|
/// returned as it is, on every system (so the rule is tested everywhere).
|
||||||
|
pub fn without_extended_prefix(text: &str) -> String {
|
||||||
|
text.replace(r"\\?\UNC\", r"\\").replace(r"\\?\", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `std::fs::canonicalize` with Windows' extended-length prefix taken off
|
||||||
|
/// (`\\?\C:\dir` is `C:\dir`): no root, record, message or git argument
|
||||||
|
/// ever keeps the prefix.
|
||||||
|
pub fn canonical(path: &Path) -> std::io::Result<PathBuf> {
|
||||||
|
std::fs::canonicalize(path).map(|real| plain_path(&real))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`without_extended_prefix`] for a path (the same path on Unix).
|
||||||
|
pub fn plain_path(path: &Path) -> PathBuf {
|
||||||
|
match path.to_str() {
|
||||||
|
Some(text) if text.contains(r"\\?\") => PathBuf::from(without_extended_prefix(text)),
|
||||||
|
_ => path.to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The longest socket path used as it is: a Unix socket's address holds
|
/// The longest socket path used as it is: a Unix socket's address holds
|
||||||
/// 104 bytes on macOS and 108 on Linux, the terminating zero included.
|
/// 104 bytes on macOS and 108 on Linux, the terminating zero included.
|
||||||
const SOCKET_PATH_MAX: usize = 100;
|
const SOCKET_PATH_MAX: usize = 100;
|
||||||
@@ -364,7 +395,7 @@ pub fn short_link_for(dir: &Path) -> std::io::Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
let dir = std::fs::canonicalize(dir)?;
|
let dir = canonical(dir)?;
|
||||||
let link = base.join(&hex::encode(Sha256::digest(dir.as_os_str().as_encoded_bytes()))[..16]);
|
let link = base.join(&hex::encode(Sha256::digest(dir.as_os_str().as_encoded_bytes()))[..16]);
|
||||||
match std::fs::symlink_metadata(&link) {
|
match std::fs::symlink_metadata(&link) {
|
||||||
Ok(meta) => {
|
Ok(meta) => {
|
||||||
@@ -480,7 +511,7 @@ impl Root {
|
|||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(RootError::Missing),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(RootError::Missing),
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
check_private_dir(&meta, false, THE_ROOT)?;
|
check_private_dir(path, &meta, false, THE_ROOT)?;
|
||||||
check_ancestors(path)?;
|
check_ancestors(path)?;
|
||||||
if !path.join(MARKER_FILE).is_file() {
|
if !path.join(MARKER_FILE).is_file() {
|
||||||
return Err(RootError::Unsafe(format!(
|
return Err(RootError::Unsafe(format!(
|
||||||
@@ -509,8 +540,7 @@ impl Root {
|
|||||||
// resolved that way too: on macOS `/tmp` is `/private/tmp`, and a
|
// resolved that way too: on macOS `/tmp` is `/private/tmp`, and a
|
||||||
// root chosen at the default must read as the default (so the
|
// root chosen at the default must read as the default (so the
|
||||||
// record goes), not as somewhere else.
|
// record goes), not as somewhere else.
|
||||||
let default =
|
let default = default_root(&canonical(home).unwrap_or_else(|_| home.to_path_buf()));
|
||||||
default_root(&std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf()));
|
|
||||||
let recorded = recorded_root(home)?;
|
let recorded = recorded_root(home)?;
|
||||||
let current = recorded.clone().unwrap_or_else(|| default.clone());
|
let current = recorded.clone().unwrap_or_else(|| default.clone());
|
||||||
let path = match (chosen, &recorded, xdg_data_home) {
|
let path = match (chosen, &recorded, xdg_data_home) {
|
||||||
@@ -554,7 +584,8 @@ impl Root {
|
|||||||
Ok(meta) => {
|
Ok(meta) => {
|
||||||
// Checked before anything is changed: a folder that holds
|
// Checked before anything is changed: a folder that holds
|
||||||
// other things is left exactly as it is.
|
// other things is left exactly as it is.
|
||||||
let open = check_private_dir(&meta, true, "the folder chosen for the books")?;
|
let open =
|
||||||
|
check_private_dir(&path, &meta, true, "the folder chosen for the books")?;
|
||||||
if !path.join(MARKER_FILE).is_file() && std::fs::read_dir(&path)?.next().is_some() {
|
if !path.join(MARKER_FILE).is_file() && std::fs::read_dir(&path)?.next().is_some() {
|
||||||
return Err(RootError::Unsafe(
|
return Err(RootError::Unsafe(
|
||||||
"the folder chosen for the books already holds other things: choose an \
|
"the folder chosen for the books already holds other things: choose an \
|
||||||
@@ -643,7 +674,7 @@ impl Root {
|
|||||||
let tightened = match std::fs::symlink_metadata(&dir) {
|
let tightened = match std::fs::symlink_metadata(&dir) {
|
||||||
Ok(meta) => tighten_if(
|
Ok(meta) => tighten_if(
|
||||||
&dir,
|
&dir,
|
||||||
check_private_dir(&meta, tighten, "a folder inside it")?,
|
check_private_dir(&dir, &meta, tighten, "a folder inside it")?,
|
||||||
)?,
|
)?,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
make_private_dir(&dir)?;
|
make_private_dir(&dir)?;
|
||||||
@@ -1184,9 +1215,9 @@ impl Root {
|
|||||||
/// The source of an adoption, made absolute: books outside the root
|
/// The source of an adoption, made absolute: books outside the root
|
||||||
/// (and not holding it) with a journal.
|
/// (and not holding it) with a journal.
|
||||||
fn adoptable(&self, source: &Path) -> Result<PathBuf, RootError> {
|
fn adoptable(&self, source: &Path) -> Result<PathBuf, RootError> {
|
||||||
let source = std::fs::canonicalize(source)
|
let source = canonical(source)
|
||||||
.map_err(|e| RootError::Adopt(format!("can't read the books given: {}", e.kind())))?;
|
.map_err(|e| RootError::Adopt(format!("can't read the books given: {}", e.kind())))?;
|
||||||
let root = std::fs::canonicalize(&self.path)?;
|
let root = canonical(&self.path)?;
|
||||||
if source.starts_with(&root) || root.starts_with(&source) {
|
if source.starts_with(&root) || root.starts_with(&source) {
|
||||||
return Err(RootError::Adopt(format!(
|
return Err(RootError::Adopt(format!(
|
||||||
"those books are inside {THE_ROOT} (or hold it): adopt books from outside it"
|
"those books are inside {THE_ROOT} (or hold it): adopt books from outside it"
|
||||||
@@ -1250,7 +1281,7 @@ impl Root {
|
|||||||
confirm: bool,
|
confirm: bool,
|
||||||
) -> Result<PutAside, RootError> {
|
) -> Result<PutAside, RootError> {
|
||||||
(|| {
|
(|| {
|
||||||
let root = std::fs::canonicalize(&self.path)?;
|
let root = canonical(&self.path)?;
|
||||||
let book = self.book_dir(id);
|
let book = self.book_dir(id);
|
||||||
if !book.join(JOURNAL_FILE).is_file() {
|
if !book.join(JOURNAL_FILE).is_file() {
|
||||||
return Err(RootError::Adopt(format!(
|
return Err(RootError::Adopt(format!(
|
||||||
@@ -1273,7 +1304,7 @@ impl Root {
|
|||||||
let (Some(parent), Some(name)) = (given.parent(), given.file_name()) else {
|
let (Some(parent), Some(name)) = (given.parent(), given.file_name()) else {
|
||||||
return Err(RootError::Adopt(format!("{shown} can't be put aside")));
|
return Err(RootError::Adopt(format!("{shown} can't be put aside")));
|
||||||
};
|
};
|
||||||
let path = std::fs::canonicalize(parent)
|
let path = canonical(parent)
|
||||||
.map_err(|e| RootError::Adopt(format!("{shown}: {}", e.kind())))?
|
.map_err(|e| RootError::Adopt(format!("{shown}: {}", e.kind())))?
|
||||||
.join(name);
|
.join(name);
|
||||||
let meta = std::fs::symlink_metadata(&path)
|
let meta = std::fs::symlink_metadata(&path)
|
||||||
@@ -1304,6 +1335,8 @@ impl Root {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Windows: a move to another disk is refused by the move
|
||||||
|
// itself (no copy fallback is asked for).
|
||||||
// A vault or its key among the other paths would go aside
|
// A vault or its key among the other paths would go aside
|
||||||
// unchecked: each comes by its own name, and is checked.
|
// unchecked: each comes by its own name, and is checked.
|
||||||
let k = items.len();
|
let k = items.len();
|
||||||
@@ -1554,10 +1587,17 @@ fn rename_new(from: &Path, to: &Path) -> std::io::Result<()> {
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if std::fs::symlink_metadata(to).is_ok() {
|
// Windows: MoveFileExW without the replace flag refuses an existing
|
||||||
return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
|
// target itself.
|
||||||
|
#[cfg(windows)]
|
||||||
|
return crate::winsec::rename_new(from, to);
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
if std::fs::symlink_metadata(to).is_ok() {
|
||||||
|
return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
|
||||||
|
}
|
||||||
|
std::fs::rename(from, to)
|
||||||
}
|
}
|
||||||
std::fs::rename(from, to)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the stored files under `old` (a books' `blobs/`) are all in
|
/// Whether the stored files under `old` (a books' `blobs/`) are all in
|
||||||
@@ -1852,6 +1892,12 @@ fn recorded_root(home: &Path) -> Result<Option<PathBuf>, RootError> {
|
|||||||
return Err(unsafe_pointer());
|
return Err(unsafe_pointer());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Windows: this account's own file that no one else can write (the
|
||||||
|
// DACL says who can open it at all; INVARIANT 3).
|
||||||
|
#[cfg(windows)]
|
||||||
|
if !crate::winsec::is_private(&pointer).unwrap_or(false) {
|
||||||
|
return Err(unsafe_pointer());
|
||||||
|
}
|
||||||
let text = std::fs::read_to_string(&pointer)?;
|
let text = std::fs::read_to_string(&pointer)?;
|
||||||
let path = PathBuf::from(text.trim_end_matches(['\n', '\r']));
|
let path = PathBuf::from(text.trim_end_matches(['\n', '\r']));
|
||||||
if path.is_absolute() {
|
if path.is_absolute() {
|
||||||
@@ -1892,7 +1938,7 @@ fn resolve_new(path: &Path) -> Result<PathBuf, RootError> {
|
|||||||
_ => break,
|
_ => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut out = std::fs::canonicalize(existing)?;
|
let mut out = canonical(existing)?;
|
||||||
for n in rest.into_iter().rev() {
|
for n in rest.into_iter().rev() {
|
||||||
out.push(n);
|
out.push(n);
|
||||||
}
|
}
|
||||||
@@ -1949,9 +1995,16 @@ fn set_mode(path: &Path, mode: u32) -> std::io::Result<()> {
|
|||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
/// Windows: the mode is the DACL, this account's alone, protected, and
|
||||||
|
/// inherited into a folder's contents (`mode` says only that the caller
|
||||||
|
/// wants it private; a folder or a file is told by what `path` is).
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn set_mode(path: &Path, _mode: u32) -> std::io::Result<()> {
|
||||||
|
crate::winsec::set_owner_only(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> {
|
fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> {
|
||||||
// Windows: the owner-only DACL comes with the Windows build (P11).
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1960,10 +2013,12 @@ fn set_mode(_path: &Path, _mode: u32) -> std::io::Result<()> {
|
|||||||
/// to make 0700 with [`tighten_if`]; without, it is refused. `what` names
|
/// to make 0700 with [`tighten_if`]; without, it is refused. `what` names
|
||||||
/// it in messages.
|
/// it in messages.
|
||||||
fn check_private_dir(
|
fn check_private_dir(
|
||||||
|
path: &Path,
|
||||||
meta: &std::fs::Metadata,
|
meta: &std::fs::Metadata,
|
||||||
tighten: bool,
|
tighten: bool,
|
||||||
what: &str,
|
what: &str,
|
||||||
) -> Result<bool, RootError> {
|
) -> Result<bool, RootError> {
|
||||||
|
let _ = path; // Unix reads the mode from `meta`; Windows reads the DACL at `path`.
|
||||||
if meta.file_type().is_symlink() {
|
if meta.file_type().is_symlink() {
|
||||||
return Err(RootError::Unsafe(format!(
|
return Err(RootError::Unsafe(format!(
|
||||||
"{what} is a symlink: it must be a real folder"
|
"{what} is a symlink: it must be a real folder"
|
||||||
@@ -1991,8 +2046,18 @@ fn check_private_dir(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(unix))]
|
// Windows: this account's own, with no one else on its access list.
|
||||||
let _ = tighten;
|
#[cfg(windows)]
|
||||||
|
if !crate::winsec::is_private(path).unwrap_or(false) {
|
||||||
|
if !tighten {
|
||||||
|
return Err(RootError::Unsafe(format!(
|
||||||
|
"other users can open {what}: run `wanderbooks setup` to make it private"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
let _ = (path, tighten);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2010,6 +2075,11 @@ fn check_ancestors(path: &Path) -> Result<(), RootError> {
|
|||||||
/// sticky bit (like `/tmp`); group write only when the group is this
|
/// sticky bit (like `/tmp`); group write only when the group is this
|
||||||
/// account's own private group (its primary group, with no other members:
|
/// account's own private group (its primary group, with no other members:
|
||||||
/// the usual umask-002 setup on many Linux systems).
|
/// the usual umask-002 setup on many Linux systems).
|
||||||
|
///
|
||||||
|
/// Windows: nothing above the root is checked. The root sits under the
|
||||||
|
/// account's own profile, whose folders the system gives that account
|
||||||
|
/// and the Administrators alone; another place is the owner's choice
|
||||||
|
/// (CHOICE, P11c: no authority states a Windows rule for ancestors).
|
||||||
fn check_dir_and_above(dir: &Path) -> Result<(), RootError> {
|
fn check_dir_and_above(dir: &Path) -> Result<(), RootError> {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -2080,6 +2150,7 @@ fn is_private_group(gid: u32) -> bool {
|
|||||||
/// it is the account's primary group, is named as the account's login is,
|
/// it is the account's primary group, is named as the account's login is,
|
||||||
/// and lists no members. A shared primary group (openSUSE's `users`) has
|
/// and lists no members. A shared primary group (openSUSE's `users`) has
|
||||||
/// another name, so it isn't.
|
/// another name, so it isn't.
|
||||||
|
#[cfg(unix)]
|
||||||
fn private_group_rule(gid: u32, primary: u32, name: &[u8], login: &[u8], members: bool) -> bool {
|
fn private_group_rule(gid: u32, primary: u32, name: &[u8], login: &[u8], members: bool) -> bool {
|
||||||
gid == primary && name == login && !members
|
gid == primary && name == login && !members
|
||||||
}
|
}
|
||||||
@@ -2216,6 +2287,21 @@ fn copy_flat_private(from: &Path, to: &Path) -> Result<u64, RootError> {
|
|||||||
mod unit {
|
mod unit {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_extended_length_prefix_is_taken_off_paths() {
|
||||||
|
assert_eq!(without_extended_prefix(r"\\?\C:\a\b"), r"C:\a\b");
|
||||||
|
assert_eq!(
|
||||||
|
without_extended_prefix(r"--git-dir=\\?\C:\a\archive.git"),
|
||||||
|
r"--git-dir=C:\a\archive.git"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
without_extended_prefix(r"\\?\UNC\server\share\x"),
|
||||||
|
r"\\server\share\x"
|
||||||
|
);
|
||||||
|
assert_eq!(without_extended_prefix("/usr/bin/git"), "/usr/bin/git");
|
||||||
|
assert_eq!(plain_path(Path::new("/a/b")), Path::new("/a/b"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_buffer_grows_while_the_lookup_says_too_small() {
|
fn a_buffer_grows_while_the_lookup_says_too_small() {
|
||||||
// Too small twice, then found: 16, 32, 64 KiB tried, in order.
|
// Too small twice, then found: 16, 32, 64 KiB tried, in order.
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
//! Windows owner-only security (P11c, D-62): what mode 0700/0600 and
|
||||||
|
//! `getuid` are on Unix.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! GOAL: the data root, its folders and files, and the local pipes are
|
||||||
|
//! this account's alone, and a process can say whose another
|
||||||
|
//! process is.
|
||||||
|
//! SOURCE: plan/served.md 3.4 ("the data root ... an owner-only DACL
|
||||||
|
//! where Unix has mode 0700"), 3.9 ("named pipes with the SID
|
||||||
|
//! check where Unix checks peer uid"), D-62; Microsoft's
|
||||||
|
//! documentation of SetNamedSecurityInfoW, SDDL strings,
|
||||||
|
//! GetNamedPipeClientProcessId and SHGetKnownFolderPath (cited,
|
||||||
|
//! not copied).
|
||||||
|
//! INVARIANTS:
|
||||||
|
//! 1. An owner-only DACL is protected (it takes nothing from the folder
|
||||||
|
//! above) and holds one allow entry, for this account's SID;
|
||||||
|
//! folders' entry is inherited by what is made inside them.
|
||||||
|
//! 2. A path is private when its owner is this account (or the
|
||||||
|
//! Administrators group a elevated account's files are owned by)
|
||||||
|
//! and no allow entry names anyone but this account, SYSTEM or the
|
||||||
|
//! Administrators; a missing DACL (everyone allowed) is not private.
|
||||||
|
//! 3. Every question that cannot be answered (a handle that won't open,
|
||||||
|
//! a token that won't read) is "no": nothing here fails open.
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::ffi::{OsStr, OsString, c_void};
|
||||||
|
use std::io;
|
||||||
|
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree};
|
||||||
|
use windows_sys::Win32::Security::Authorization::{
|
||||||
|
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
|
||||||
|
GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT, SetNamedSecurityInfoW,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::Security::{
|
||||||
|
ACL, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetLengthSid, GetSecurityDescriptorDacl,
|
||||||
|
GetTokenInformation, IsWellKnownSid, OWNER_SECURITY_INFORMATION,
|
||||||
|
PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES,
|
||||||
|
TOKEN_QUERY, TOKEN_USER, TokenUser, WinBuiltinAdministratorsSid, WinLocalSystemSid,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::System::Com::CoTaskMemFree;
|
||||||
|
use windows_sys::Win32::System::Threading::{
|
||||||
|
GetCurrentProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::UI::Shell::{
|
||||||
|
FOLDERID_LocalAppData, FOLDERID_Profile, KF_FLAG_DEFAULT, SHGetKnownFolderPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// An ACCESS_ALLOWED_ACE_TYPE entry's type byte.
|
||||||
|
const ACCESS_ALLOWED: u8 = 0;
|
||||||
|
|
||||||
|
/// A handle closed when dropped.
|
||||||
|
struct Owned(HANDLE);
|
||||||
|
|
||||||
|
impl Drop for Owned {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: a handle this value opened and owns, closed once.
|
||||||
|
unsafe { CloseHandle(self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A SID: an owned copy of its bytes.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Sid(Vec<u64>);
|
||||||
|
|
||||||
|
impl Sid {
|
||||||
|
/// A copy of the SID `sid` points at.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `sid` must point at a valid SID.
|
||||||
|
unsafe fn copy_of(sid: PSID) -> Sid {
|
||||||
|
// SAFETY: the caller's promise that `sid` is a valid SID.
|
||||||
|
let len = unsafe { GetLengthSid(sid) } as usize;
|
||||||
|
let mut words = vec![0u64; len.div_ceil(8)];
|
||||||
|
// SAFETY: `len` bytes of a valid SID into a buffer of at least
|
||||||
|
// `len` bytes; the buffer is 8-byte aligned.
|
||||||
|
unsafe { std::ptr::copy_nonoverlapping(sid.cast::<u8>(), words.as_mut_ptr().cast(), len) };
|
||||||
|
Sid(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_psid(&self) -> PSID {
|
||||||
|
self.0.as_ptr() as PSID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SID as the text SDDL uses (`S-1-5-21-...`).
|
||||||
|
pub fn to_text(&self) -> io::Result<String> {
|
||||||
|
let mut out: *mut u16 = std::ptr::null_mut();
|
||||||
|
// SAFETY: a valid SID, and an out pointer to receive an allocation.
|
||||||
|
if unsafe { ConvertSidToStringSidW(self.as_psid(), &mut out) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let text = from_wide_ptr(out);
|
||||||
|
// SAFETY: the allocation ConvertSidToStringSidW made, freed once.
|
||||||
|
unsafe { LocalFree(out.cast()) };
|
||||||
|
text.into_string()
|
||||||
|
.map_err(|_| io::Error::other("a SID that is not text"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for Sid {
|
||||||
|
fn eq(&self, other: &Sid) -> bool {
|
||||||
|
// SAFETY: both are valid SIDs copied from the system.
|
||||||
|
unsafe { EqualSid(self.as_psid(), other.as_psid()) != 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for Sid {}
|
||||||
|
|
||||||
|
/// The text a NUL-terminated wide string holds.
|
||||||
|
fn from_wide_ptr(p: *const u16) -> OsString {
|
||||||
|
let mut len = 0;
|
||||||
|
// SAFETY: the caller passes a NUL-terminated string the system made.
|
||||||
|
while unsafe { *p.add(len) } != 0 {
|
||||||
|
len += 1;
|
||||||
|
}
|
||||||
|
// SAFETY: `len` units were just read from it.
|
||||||
|
OsString::from_wide(unsafe { std::slice::from_raw_parts(p, len) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path or text as a NUL-terminated wide string.
|
||||||
|
pub fn wide(s: &OsStr) -> Vec<u16> {
|
||||||
|
s.encode_wide().chain(std::iter::once(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user SID of the token `token` (TOKEN_QUERY).
|
||||||
|
fn token_user(token: HANDLE) -> io::Result<Sid> {
|
||||||
|
let mut needed = 0u32;
|
||||||
|
// SAFETY: asking only for the size; the call is expected to fail
|
||||||
|
// with "insufficient buffer" and set `needed`.
|
||||||
|
unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed) };
|
||||||
|
if needed == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// 8-byte aligned for TOKEN_USER's pointers.
|
||||||
|
let mut buf = vec![0u64; (needed as usize).div_ceil(8)];
|
||||||
|
// SAFETY: a buffer of at least `needed` bytes.
|
||||||
|
if unsafe {
|
||||||
|
GetTokenInformation(
|
||||||
|
token,
|
||||||
|
TokenUser,
|
||||||
|
buf.as_mut_ptr().cast(),
|
||||||
|
needed,
|
||||||
|
&mut needed,
|
||||||
|
)
|
||||||
|
} == 0
|
||||||
|
{
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// SAFETY: the buffer now holds a TOKEN_USER whose SID points inside it.
|
||||||
|
Ok(unsafe { Sid::copy_of((*buf.as_ptr().cast::<TOKEN_USER>()).User.Sid) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This process's account.
|
||||||
|
pub fn current_user() -> io::Result<Sid> {
|
||||||
|
let mut token: HANDLE = std::ptr::null_mut();
|
||||||
|
// SAFETY: the pseudo-handle of this process, and an out handle.
|
||||||
|
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let token = Owned(token);
|
||||||
|
token_user(token.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account process `pid` runs as. An error when it can't be told
|
||||||
|
/// (a process of another account usually can't be opened at all).
|
||||||
|
pub fn process_user(pid: u32) -> io::Result<Sid> {
|
||||||
|
// SAFETY: a plain open of a process by number.
|
||||||
|
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||||
|
if process.is_null() {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let process = Owned(process);
|
||||||
|
let mut token: HANDLE = std::ptr::null_mut();
|
||||||
|
// SAFETY: a process handle just opened, and an out handle.
|
||||||
|
if unsafe { OpenProcessToken(process.0, TOKEN_QUERY, &mut token) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let token = Owned(token);
|
||||||
|
token_user(token.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A security descriptor the system allocated, freed when dropped.
|
||||||
|
struct Descriptor(PSECURITY_DESCRIPTOR);
|
||||||
|
|
||||||
|
impl Drop for Descriptor {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: an allocation of the system's, freed once.
|
||||||
|
unsafe { LocalFree(self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The descriptor of SDDL `text`.
|
||||||
|
fn from_sddl(text: &str) -> io::Result<Descriptor> {
|
||||||
|
let wide = wide(OsStr::new(text));
|
||||||
|
let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
|
||||||
|
// SAFETY: a NUL-terminated string and an out pointer.
|
||||||
|
let ok = unsafe {
|
||||||
|
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||||
|
wide.as_ptr(),
|
||||||
|
SDDL_REVISION_1,
|
||||||
|
&mut sd,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(Descriptor(sd))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The SDDL of a DACL that holds one allow entry, full access for this
|
||||||
|
/// account, protected; for a folder, inherited by what it holds.
|
||||||
|
fn owner_only_sddl(folder: bool) -> io::Result<String> {
|
||||||
|
let sid = current_user()?.to_text()?;
|
||||||
|
Ok(if folder {
|
||||||
|
format!("D:P(A;OICI;FA;;;{sid})")
|
||||||
|
} else {
|
||||||
|
format!("D:P(A;;FA;;;{sid})")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `path` this account's alone (INVARIANT 1): the mode 0700 of a
|
||||||
|
/// folder, the 0600 of a file.
|
||||||
|
pub fn set_owner_only(path: &Path) -> io::Result<()> {
|
||||||
|
let folder = std::fs::metadata(path)?.is_dir();
|
||||||
|
let sd = from_sddl(&owner_only_sddl(folder)?)?;
|
||||||
|
let mut present = 0;
|
||||||
|
let mut defaulted = 0;
|
||||||
|
let mut dacl: *mut ACL = std::ptr::null_mut();
|
||||||
|
// SAFETY: a descriptor the system made, and out values.
|
||||||
|
if unsafe { GetSecurityDescriptorDacl(sd.0, &mut present, &mut dacl, &mut defaulted) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let name = wide(path.as_os_str());
|
||||||
|
// SAFETY: a NUL-terminated path, no owner or group to set, and a DACL
|
||||||
|
// that lives in `sd` past the call.
|
||||||
|
let status = unsafe {
|
||||||
|
SetNamedSecurityInfoW(
|
||||||
|
name.as_ptr(),
|
||||||
|
SE_FILE_OBJECT,
|
||||||
|
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
dacl,
|
||||||
|
std::ptr::null(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if status != 0 {
|
||||||
|
return Err(io::Error::from_raw_os_error(status as i32));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the owner and the allow entries of `path` are this account's
|
||||||
|
/// (INVARIANT 2): the answer to "do others have access".
|
||||||
|
pub fn is_private(path: &Path) -> io::Result<bool> {
|
||||||
|
let me = current_user()?;
|
||||||
|
let name = wide(path.as_os_str());
|
||||||
|
let mut owner: PSID = std::ptr::null_mut();
|
||||||
|
let mut dacl: *mut ACL = std::ptr::null_mut();
|
||||||
|
let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
|
||||||
|
// SAFETY: a NUL-terminated path and out pointers; the descriptor the
|
||||||
|
// call allocates holds the owner and the DACL and is freed below.
|
||||||
|
let status = unsafe {
|
||||||
|
GetNamedSecurityInfoW(
|
||||||
|
name.as_ptr(),
|
||||||
|
SE_FILE_OBJECT,
|
||||||
|
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
|
||||||
|
&mut owner,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
&mut dacl,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
&mut sd,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if status != 0 {
|
||||||
|
return Err(io::Error::from_raw_os_error(status as i32));
|
||||||
|
}
|
||||||
|
let _free = Descriptor(sd);
|
||||||
|
// SAFETY: `owner` points into `sd`, alive here.
|
||||||
|
let owner_ok = !owner.is_null()
|
||||||
|
&& (unsafe { EqualSid(owner, me.as_psid()) } != 0
|
||||||
|
|| unsafe { IsWellKnownSid(owner, WinBuiltinAdministratorsSid) } != 0);
|
||||||
|
if !owner_ok || dacl.is_null() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
// SAFETY: `dacl` points into `sd`; its entries are read in place.
|
||||||
|
let count = unsafe { (*dacl).AceCount };
|
||||||
|
for i in 0..u32::from(count) {
|
||||||
|
let mut ace: *mut c_void = std::ptr::null_mut();
|
||||||
|
// SAFETY: an index below the entry count of a valid ACL.
|
||||||
|
if unsafe { GetAce(dacl, i, &mut ace) } == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
// SAFETY: every ACE starts with its header.
|
||||||
|
let header = unsafe { *ace.cast::<windows_sys::Win32::Security::ACE_HEADER>() };
|
||||||
|
if header.AceType != ACCESS_ALLOWED {
|
||||||
|
// Deny entries only take access away; the object-specific
|
||||||
|
// allow types don't occur on files, and one is not private.
|
||||||
|
if (1..=3).contains(&header.AceType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
// SAFETY: an ACCESS_ALLOWED_ACE's SID starts at SidStart.
|
||||||
|
let sid = unsafe {
|
||||||
|
std::ptr::addr_of_mut!(
|
||||||
|
(*ace.cast::<windows_sys::Win32::Security::ACCESS_ALLOWED_ACE>()).SidStart
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.cast::<c_void>();
|
||||||
|
// SAFETY: a valid SID inside the ACE.
|
||||||
|
let ours = unsafe {
|
||||||
|
EqualSid(sid, me.as_psid()) != 0
|
||||||
|
|| IsWellKnownSid(sid, WinLocalSystemSid) != 0
|
||||||
|
|| IsWellKnownSid(sid, WinBuiltinAdministratorsSid) != 0
|
||||||
|
};
|
||||||
|
if !ours {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A known folder's path, from the shell (never an environment variable
|
||||||
|
/// the process that started us chose).
|
||||||
|
fn known_folder(id: &windows_sys::core::GUID) -> Option<PathBuf> {
|
||||||
|
let mut out: *mut u16 = std::ptr::null_mut();
|
||||||
|
// SAFETY: a known-folder id, the current account's token (null), and an
|
||||||
|
// out pointer for a string the shell allocates.
|
||||||
|
let hr =
|
||||||
|
unsafe { SHGetKnownFolderPath(id, KF_FLAG_DEFAULT as u32, std::ptr::null_mut(), &mut out) };
|
||||||
|
if hr != 0 || out.is_null() {
|
||||||
|
if !out.is_null() {
|
||||||
|
// SAFETY: freed once, as the API asks even on failure.
|
||||||
|
unsafe { CoTaskMemFree(out.cast()) };
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path = PathBuf::from(from_wide_ptr(out));
|
||||||
|
// SAFETY: the shell's allocation, freed once.
|
||||||
|
unsafe { CoTaskMemFree(out.cast()) };
|
||||||
|
path.is_absolute().then_some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This account's profile folder (`C:\Users\name`).
|
||||||
|
pub fn profile_folder() -> Option<PathBuf> {
|
||||||
|
known_folder(&FOLDERID_Profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This account's local application data folder (`%LOCALAPPDATA%`,
|
||||||
|
/// wherever it was redirected to).
|
||||||
|
pub fn local_app_data() -> Option<PathBuf> {
|
||||||
|
known_folder(&FOLDERID_LocalAppData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename `from` to `to`, never over something already at `to`, and
|
||||||
|
/// written through to disk.
|
||||||
|
pub fn rename_new(from: &Path, to: &Path) -> io::Result<()> {
|
||||||
|
use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW};
|
||||||
|
let (f, t) = (wide(from.as_os_str()), wide(to.as_os_str()));
|
||||||
|
// SAFETY: two NUL-terminated paths; no REPLACE_EXISTING flag, so an
|
||||||
|
// existing target is an error.
|
||||||
|
if unsafe { MoveFileExW(f.as_ptr(), t.as_ptr(), MOVEFILE_WRITE_THROUGH) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Security attributes that make a kernel object (a named pipe) this
|
||||||
|
/// account's alone, for a call that takes `SECURITY_ATTRIBUTES`.
|
||||||
|
pub struct OwnerOnly {
|
||||||
|
_descriptor: Descriptor,
|
||||||
|
attributes: SECURITY_ATTRIBUTES,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: the descriptor is immutable memory that nothing else touches.
|
||||||
|
unsafe impl Send for OwnerOnly {}
|
||||||
|
unsafe impl Sync for OwnerOnly {}
|
||||||
|
|
||||||
|
impl OwnerOnly {
|
||||||
|
/// A descriptor with one allow entry, full access for this account.
|
||||||
|
pub fn new() -> io::Result<OwnerOnly> {
|
||||||
|
let sddl = format!("D:P(A;;GA;;;{})", current_user()?.to_text()?);
|
||||||
|
let descriptor = from_sddl(&sddl)?;
|
||||||
|
let attributes = SECURITY_ATTRIBUTES {
|
||||||
|
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||||
|
lpSecurityDescriptor: descriptor.0,
|
||||||
|
bInheritHandle: 0,
|
||||||
|
};
|
||||||
|
Ok(OwnerOnly {
|
||||||
|
_descriptor: descriptor,
|
||||||
|
attributes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pointer `create_with_security_attributes_raw` takes; good while
|
||||||
|
/// this lives.
|
||||||
|
pub fn as_raw(&self) -> *mut c_void {
|
||||||
|
std::ptr::addr_of!(self.attributes).cast_mut().cast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This process's account, asked of the system once.
|
||||||
|
fn me() -> Option<&'static Sid> {
|
||||||
|
static ME: std::sync::OnceLock<Option<Sid>> = std::sync::OnceLock::new();
|
||||||
|
ME.get_or_init(|| current_user().ok()).as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether process `pid` runs as this account (INVARIANT 3: any doubt is
|
||||||
|
/// "no").
|
||||||
|
pub fn pid_is_us(pid: u32) -> bool {
|
||||||
|
pid != 0 && me().is_some_and(|me| process_user(pid).is_ok_and(|them| &them == me))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process at the client end of the pipe `handle` (a server's).
|
||||||
|
pub fn pipe_client_pid(pipe: &impl std::os::windows::io::AsRawHandle) -> io::Result<u32> {
|
||||||
|
let mut pid = 0u32;
|
||||||
|
// SAFETY: a pipe handle the caller owns and an out value.
|
||||||
|
if unsafe {
|
||||||
|
windows_sys::Win32::System::Pipes::GetNamedPipeClientProcessId(
|
||||||
|
pipe.as_raw_handle(),
|
||||||
|
&mut pid,
|
||||||
|
)
|
||||||
|
} == 0
|
||||||
|
{
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process at the server end of the pipe `handle` (a client's).
|
||||||
|
pub fn pipe_server_pid(pipe: &impl std::os::windows::io::AsRawHandle) -> io::Result<u32> {
|
||||||
|
let mut pid = 0u32;
|
||||||
|
// SAFETY: a pipe handle the caller owns and an out value.
|
||||||
|
if unsafe {
|
||||||
|
windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId(
|
||||||
|
pipe.as_raw_handle(),
|
||||||
|
&mut pid,
|
||||||
|
)
|
||||||
|
} == 0
|
||||||
|
{
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many bytes wait to be read on the pipe `handle`, without reading
|
||||||
|
/// them. A pipe whose other end has gone is `ERROR_BROKEN_PIPE`.
|
||||||
|
pub fn pipe_bytes_waiting(pipe: &impl std::os::windows::io::AsRawHandle) -> io::Result<u32> {
|
||||||
|
let mut avail = 0u32;
|
||||||
|
// SAFETY: a pipe handle the caller owns; only the available count is
|
||||||
|
// asked for.
|
||||||
|
let ok = unsafe {
|
||||||
|
windows_sys::Win32::System::Pipes::PeekNamedPipe(
|
||||||
|
pipe.as_raw_handle(),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
&mut avail,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(avail)
|
||||||
|
}
|
||||||
@@ -125,13 +125,36 @@ fn line_types(dir: &Path) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every file under `dir` (relative path → bytes).
|
/// Whether `p` is a lock file (`wanderbooks.lock`, `books.lock`, and any
|
||||||
|
/// other `*.lock`): it holds no data.
|
||||||
|
fn is_lock_file(p: &Path) -> bool {
|
||||||
|
p.extension().is_some_and(|x| x == "lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `p` is the live SQLite database or one of its companions.
|
||||||
|
fn is_live_database(p: &Path) -> bool {
|
||||||
|
let name = p.file_name().unwrap().to_string_lossy().into_owned();
|
||||||
|
let db = db_file();
|
||||||
|
name == db
|
||||||
|
|| ["-wal", "-shm", "-journal"]
|
||||||
|
.iter()
|
||||||
|
.any(|x| name == format!("{db}{x}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every file under `dir` (relative path → bytes), the live database, its
|
||||||
|
/// companions and the lock files left out.
|
||||||
fn tree(dir: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
|
fn tree(dir: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
|
||||||
fn walk(root: &Path, d: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
|
fn walk(root: &Path, d: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
|
||||||
for e in std::fs::read_dir(d).unwrap() {
|
for e in std::fs::read_dir(d).unwrap() {
|
||||||
let p = e.unwrap().path();
|
let p = e.unwrap().path();
|
||||||
if p.is_dir() {
|
if p.is_dir() {
|
||||||
walk(root, &p, out);
|
walk(root, &p, out);
|
||||||
|
} else if is_live_database(&p) || is_lock_file(&p) {
|
||||||
|
// An open SQLite connection holds byte-range locks on its
|
||||||
|
// database file on Windows, and so does the store on its
|
||||||
|
// lock files (LockFileEx is mandatory there): a plain read
|
||||||
|
// is refused. The tests compare the journal, blobs and
|
||||||
|
// kept-aside files.
|
||||||
} else {
|
} else {
|
||||||
out.insert(
|
out.insert(
|
||||||
p.strip_prefix(root).unwrap().to_path_buf(),
|
p.strip_prefix(root).unwrap().to_path_buf(),
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! The data root on Windows (P11c, D-62): the same promises as Unix's
|
||||||
|
//! mode 0700, kept with an owner-only DACL. Runs on the Windows job only.
|
||||||
|
#![cfg(windows)]
|
||||||
|
|
||||||
|
use ledger_store::root::{Root, RootError, default_root};
|
||||||
|
use ledger_store::winsec;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn tmp(tag: &str) -> tempfile::TempDir {
|
||||||
|
tempfile::Builder::new()
|
||||||
|
.prefix(&format!("wb-winroot-{tag}-"))
|
||||||
|
.tempdir()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_root_sits_under_local_app_data() {
|
||||||
|
let home = tmp("default");
|
||||||
|
assert_eq!(
|
||||||
|
default_root(home.path()),
|
||||||
|
home.path()
|
||||||
|
.join("AppData")
|
||||||
|
.join("Local")
|
||||||
|
.join("wanderbooks")
|
||||||
|
);
|
||||||
|
// This account's own home: the shell's LocalAppData, wherever it is.
|
||||||
|
let own = ledger_store::root::account_home().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
default_root(&own),
|
||||||
|
winsec::local_app_data().unwrap().join("wanderbooks")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_makes_a_private_root_and_its_folders() {
|
||||||
|
let home = tmp("setup");
|
||||||
|
let (root, report) = Root::setup(home.path(), None, None).unwrap();
|
||||||
|
// The default root of the home as the system names it (its long name,
|
||||||
|
// no `\\?\` prefix; TEMP may hold an 8.3 short name).
|
||||||
|
let real_home = ledger_store::root::canonical(home.path()).unwrap();
|
||||||
|
assert_eq!(root.path(), default_root(&real_home));
|
||||||
|
assert!(!root.path().to_string_lossy().starts_with(r"\\?\"));
|
||||||
|
assert!(report.created && !report.tightened && !report.recorded);
|
||||||
|
assert!(winsec::is_private(root.path()).unwrap());
|
||||||
|
for sub in ["books", "run", "legacy"] {
|
||||||
|
assert!(winsec::is_private(&root.path().join(sub)).unwrap(), "{sub}");
|
||||||
|
}
|
||||||
|
// Again: nothing to do.
|
||||||
|
let (_, again) = Root::setup(home.path(), None, None).unwrap();
|
||||||
|
assert!(!again.created && !again.tightened && !again.recorded);
|
||||||
|
assert!(Root::find_in(home.path(), None).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Let everyone into `path` and what it holds, as `icacls` does.
|
||||||
|
fn open_to_everyone(path: &Path) {
|
||||||
|
let out = std::process::Command::new("icacls")
|
||||||
|
.arg(path)
|
||||||
|
.args(["/grant", "*S-1-1-0:(OI)(CI)F"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "{out:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_root_others_can_open_is_refused_and_setup_makes_it_private_again() {
|
||||||
|
let home = tmp("loose");
|
||||||
|
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||||
|
open_to_everyone(root.path());
|
||||||
|
assert!(!winsec::is_private(root.path()).unwrap());
|
||||||
|
assert!(matches!(Root::open(root.path()), Err(RootError::Unsafe(_))));
|
||||||
|
let (_, report) = Root::setup(home.path(), None, None).unwrap();
|
||||||
|
assert!(report.tightened);
|
||||||
|
assert!(winsec::is_private(root.path()).unwrap());
|
||||||
|
assert!(Root::open(root.path()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_is_made_in_a_private_folder_is_private() {
|
||||||
|
let home = tmp("inherit");
|
||||||
|
let (root, _) = Root::setup(home.path(), None, None).unwrap();
|
||||||
|
let file = root.path().join("books").join("note.txt");
|
||||||
|
std::fs::write(&file, b"x").unwrap();
|
||||||
|
assert!(winsec::is_private(&file).unwrap());
|
||||||
|
let sub = root.path().join("books").join("inner");
|
||||||
|
std::fs::create_dir(&sub).unwrap();
|
||||||
|
assert!(winsec::is_private(&sub).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_made_owner_only_is_private_and_a_loosened_one_is_not() {
|
||||||
|
let dir = tmp("file");
|
||||||
|
let file = dir.path().join("f");
|
||||||
|
std::fs::write(&file, b"x").unwrap();
|
||||||
|
winsec::set_owner_only(&file).unwrap();
|
||||||
|
assert!(winsec::is_private(&file).unwrap());
|
||||||
|
let out = std::process::Command::new("icacls")
|
||||||
|
.arg(&file)
|
||||||
|
.args(["/grant", "*S-1-1-0:R"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "{out:?}");
|
||||||
|
assert!(!winsec::is_private(&file).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rename_never_goes_over_what_is_there() {
|
||||||
|
let dir = tmp("rename");
|
||||||
|
let (a, b, c) = (
|
||||||
|
dir.path().join("a"),
|
||||||
|
dir.path().join("b"),
|
||||||
|
dir.path().join("c"),
|
||||||
|
);
|
||||||
|
std::fs::write(&a, b"a").unwrap();
|
||||||
|
std::fs::write(&b, b"b").unwrap();
|
||||||
|
let err = winsec::rename_new(&a, &b).unwrap_err();
|
||||||
|
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists, "{err:?}");
|
||||||
|
assert_eq!(std::fs::read(&b).unwrap(), b"b");
|
||||||
|
winsec::rename_new(&a, &c).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&c).unwrap(), b"a");
|
||||||
|
assert!(!a.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_process_runs_as_the_account_that_the_token_says() {
|
||||||
|
let me = winsec::current_user().unwrap();
|
||||||
|
assert_eq!(winsec::process_user(std::process::id()).unwrap(), me);
|
||||||
|
assert!(me.to_text().unwrap().starts_with("S-1-5-"));
|
||||||
|
}
|
||||||
@@ -286,6 +286,29 @@ request (its task, or while it holds the guard) sets `stopped`, and every
|
|||||||
later request, on every session, is refused until a restart, which
|
later request, on every session, is refused until a restart, which
|
||||||
re-verifies the journal.
|
re-verifies the journal.
|
||||||
|
|
||||||
|
## The local transport and the Windows arms (`ledger-mcp::ipc`, `ledger-store::winsec`, P11c, D-62)
|
||||||
|
|
||||||
|
The books server, the host and everything that talks to them meet on
|
||||||
|
channels only this account can open (plan 3.9). `ledger_mcp::ipc` is the
|
||||||
|
one seam: `Endpoint` (a socket in the root's `run/` on Unix; the pipe
|
||||||
|
`\\.\pipe\wanderbooks-<hash of run/>-<name>` on Windows), `Listener`
|
||||||
|
(`bind`, `accept`), `connect` and `connect_std` (blocking, with a read
|
||||||
|
limit), the `Peer` trait (`peer_is_us`: the effective uid on Unix; the
|
||||||
|
user SID of the process at the other end of the pipe on Windows), and
|
||||||
|
`stop_signals`. No other file names a socket or a pipe, a peer check or a
|
||||||
|
signal. HTTP to the server goes through `ledger_host::channel::http_builder`
|
||||||
|
(reqwest's Unix socket or named pipe).
|
||||||
|
|
||||||
|
`ledger_store::winsec` (Windows only) holds the rest of the Windows FFI:
|
||||||
|
the owner-only DACL (`set_owner_only`, `is_private`), the account's SID,
|
||||||
|
the shell's known folders, `MoveFileExW` for the move that must not
|
||||||
|
replace, and the pipe process ids. `ledger_store::lock` is the one file
|
||||||
|
lock seam (std's, which is `flock` and `LockFileEx`). `seccomp` and
|
||||||
|
`close_range` remain Linux-only, doing nothing elsewhere (D-32). Windows
|
||||||
|
CI is the evidence for every arm that cannot run on the Linux machines
|
||||||
|
that wrote it; `cargo check --target x86_64-pc-windows-msvc` (the target is
|
||||||
|
pinned in `mise.toml`) type-checks them, clippy included.
|
||||||
|
|
||||||
## bookspec (`bookspec/`, P13)
|
## bookspec (`bookspec/`, P13)
|
||||||
|
|
||||||
bookspec is the formal accounting specification (D-34): every rule stated
|
bookspec is the formal accounting specification (D-34): every rule stated
|
||||||
|
|||||||
+41
-10
@@ -178,16 +178,47 @@ keyring on every OS (tokens are 0600 files under the data root, unit U0e),
|
|||||||
so nothing about tokens is OS-specific now. The Windows host is a port,
|
so nothing about tokens is OS-specific now. The Windows host is a port,
|
||||||
not a `cfg` gap. Today Unix-only, found by grep (2026-10-29):
|
not a `cfg` gap. Today Unix-only, found by grep (2026-10-29):
|
||||||
|
|
||||||
- `ledger-host`: `bridge`, `host`, `start`, `opener` and others are
|
- ~~`ledger-host`: `bridge`, `host`, `start`, `opener` and others are
|
||||||
`#[cfg(unix)]` modules; `host.rs`/`start.rs` use `setsid`, `umask`,
|
`#[cfg(unix)]` modules; `host.rs`/`start.rs` use `setsid`, `umask`,
|
||||||
`close_range`, `geteuid`, Unix sockets and `libc` unguarded;
|
`close_range`, `geteuid`, Unix sockets and `libc` unguarded;
|
||||||
`commands.rs` calls `isatty`/`tcgetpgrp`.
|
`commands.rs` calls `isatty`/`tcgetpgrp`.~~ Landed (P11c): every module
|
||||||
- `ledger-mcp`: the owner-only socket check reads peer credentials
|
builds on every OS. The channels are `ledger_mcp::ipc` (below); the host
|
||||||
|
starts detached (`DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`, leaving
|
||||||
|
the opener's job if it lets it) where Unix uses `setsid`, with a
|
||||||
|
Windows environment list in `start.rs::host_env`; `close_range` and the
|
||||||
|
`umask` have nothing to do there (a Windows child inherits no handle it
|
||||||
|
is not given); the terminal checks use `IsTerminal`; the stop signals
|
||||||
|
are Ctrl+C, Ctrl+Break, console close and shutdown
|
||||||
|
(`ipc::stop_signals`); a replaced program is told by its size and last
|
||||||
|
write, since a running program cannot be replaced in place.
|
||||||
|
- ~~`ledger-mcp`: the owner-only socket check reads peer credentials
|
||||||
(`SO_PEERCRED`, `getpeereid`); `admin`/`local` listen on Unix sockets.
|
(`SO_PEERCRED`, `getpeereid`); `admin`/`local` listen on Unix sockets.
|
||||||
Windows needs named pipes with a peer SID check (plan 3.9).
|
Windows needs named pipes with a peer SID check (plan 3.9).~~ Landed
|
||||||
- `ledger-store`: `ops.rs` takes `flock` locks; `root.rs` has no owner-only
|
(P11c): `ledger_mcp::ipc` is the one seam. A pipe is
|
||||||
DACL (`set_mode` is a no-op off Unix) and the data root falls back to
|
`\\.\pipe\wanderbooks-<hash of the root's run/ folder>-<name>`, made with
|
||||||
`%USERPROFILE%`, not the known-folder `%LOCALAPPDATA%`; `refuse.rs`
|
a DACL of one allow entry (this account's SID), remote clients refused,
|
||||||
(seccomp) and `renameat2` are Linux-only (macOS needs its own
|
the first instance made once, and 16 idle instances waiting; each end
|
||||||
equivalents or `cfg` fences).
|
checks the other's process (`GetNamedPipeClientProcessId` /
|
||||||
- The `portable` job runs Windows as allowed-to-fail until that port lands.
|
`GetNamedPipeServerProcessId`, then that process's token user) against
|
||||||
|
its own SID. Clients open with `SECURITY_IDENTIFICATION`.
|
||||||
|
- ~~`ledger-store`: `ops.rs` takes `flock` locks; `root.rs` has no
|
||||||
|
owner-only DACL (`set_mode` is a no-op off Unix) and the data root falls
|
||||||
|
back to `%USERPROFILE%`, not the known-folder `%LOCALAPPDATA%`;
|
||||||
|
`refuse.rs` (seccomp) and `renameat2` are Linux-only (macOS needs its
|
||||||
|
own equivalents or `cfg` fences).~~ Landed (P11c): `lock.rs` is the one
|
||||||
|
lock seam (std's `try_lock`, `LockFileEx` on Windows); `winsec.rs` sets
|
||||||
|
and reads the owner-only DACL and finds the profile and `LocalAppData`
|
||||||
|
from the shell's known folders; `rename_new` is `MoveFileExW` without
|
||||||
|
the replace flag. `refuse.rs` (seccomp, a test fault injector) and the
|
||||||
|
host's `close_range` stay Linux-only on purpose (D-32: nothing here
|
||||||
|
confines a process, so nothing is promised where they are absent).
|
||||||
|
macOS, which has no `renameat2`, still looks before it moves.
|
||||||
|
- Windows file semantics the port met (P11c): an `append(true)` handle has
|
||||||
|
no right to truncate, so the chain log and the journal cut a torn tail
|
||||||
|
or roll back a failed write through a short-lived write handle
|
||||||
|
(`chain::truncate`); a held `LockFileEx` lock is mandatory, so locks sit
|
||||||
|
on dedicated `*.lock` files and tests never read those; git refuses the
|
||||||
|
`\\?\` path `canonicalize` returns (`root::plain_path`). std opens every
|
||||||
|
file with read, write and delete sharing, so a rename never meets an
|
||||||
|
open handle of ours.
|
||||||
|
- The `portable` job runs Windows as a gate (`experimental: false`), since the port landed (P11c).
|
||||||
@@ -3670,6 +3670,64 @@ it); the digest's `since_seq` comparison (`>`), its `as_of` bound, the
|
|||||||
`report_status`'s three open conditions; the open-drafts refusal's
|
`report_status`'s three open conditions; the open-drafts refusal's
|
||||||
`<= as_of` and its decision; the attention `awaiting_evidence` branch.
|
`<= as_of` and its decision; the attention `awaiting_evidence` branch.
|
||||||
|
|
||||||
|
## P11c: the native Windows port (2026-10-29, D-62)
|
||||||
|
|
||||||
|
The Windows arms were written on Linux and type-checked and linted for
|
||||||
|
`x86_64-pc-windows-msvc` (`cargo check` and `cargo clippy --workspace
|
||||||
|
--all-targets --target x86_64-pc-windows-msvc -D warnings`, both clean);
|
||||||
|
they have not run. The `portable (windows-latest)` job, now a gate, is the
|
||||||
|
evidence, and its first run is the first run of every test marked
|
||||||
|
"Windows" below.
|
||||||
|
|
||||||
|
- **`ledger-store` `tests/windows_root.rs`** (Windows only):
|
||||||
|
`the_default_root_sits_under_local_app_data`,
|
||||||
|
`setup_makes_a_private_root_and_its_folders` (the DACL of the root and of
|
||||||
|
`books`, `run`, `legacy`), `a_root_others_can_open_is_refused_and_setup_makes_it_private_again`
|
||||||
|
(Everyone granted with `icacls`, then refused, then tightened),
|
||||||
|
`what_is_made_in_a_private_folder_is_private` (inheritance),
|
||||||
|
`a_file_made_owner_only_is_private_and_a_loosened_one_is_not`,
|
||||||
|
`a_rename_never_goes_over_what_is_there`,
|
||||||
|
`this_process_runs_as_the_account_that_the_token_says`.
|
||||||
|
- **`ledger-store` `src/ops.rs`:** `a_viewer_s_presence_is_a_shared_lock_others_can_see`
|
||||||
|
(every OS; the lock seam).
|
||||||
|
- **`ledger-mcp` `src/ipc.rs`** (every OS):
|
||||||
|
`a_listener_and_a_client_talk_and_each_knows_the_other_is_us`,
|
||||||
|
`a_blocking_client_times_out_and_reads_the_end_when_the_server_goes`,
|
||||||
|
`many_clients_at_once_all_connect` (48 at once: the idle pool),
|
||||||
|
`nothing_listening_is_not_found`; Windows only:
|
||||||
|
`a_pipe_is_per_root_and_cannot_be_taken_twice`.
|
||||||
|
`admin.rs`'s fake-server tests (`an_answer_is_one_line_of_at_most_four_mib`,
|
||||||
|
`no_server_means_nothing_was_sent`) now run over `ipc` on every OS.
|
||||||
|
- **`ledger-host` `tests/windows_pipe.rs`:** `a_second_user_cannot_open_the_pipe`
|
||||||
|
(Windows only). A second account cannot be made on the runner, so it
|
||||||
|
checks the two things that keep one out: the pipe's DACL names this
|
||||||
|
account alone, and a SYSTEM process (`services.exe`) is not taken for
|
||||||
|
this account by the SID check.
|
||||||
|
- **`ledger-host`:** `start.rs` `windows_tests`, `host.rs`
|
||||||
|
`a_folder_the_host_can_write_isnt_a_sandbox` and
|
||||||
|
`only_a_file_has_an_identity_and_a_replaced_one_another` (Windows
|
||||||
|
variants of the Unix tests of the same names).
|
||||||
|
- **The over-the-wire suite** (`ledger-cli` `tests/common/mod.rs`) now
|
||||||
|
runs on Windows: `Home` finds its root by `default_root`, children get
|
||||||
|
`clean_env`, `mcp_http` reaches the server over its pipe, folders are
|
||||||
|
made private by `private_folder`. Tests that signal processes
|
||||||
|
(`libc::kill`), use Unix sockets, modes, symlinks or `/proc` stay
|
||||||
|
`cfg(unix)` / `cfg(target_os = "linux")`. `one_executable.rs`'s
|
||||||
|
`without_local_sockets_the_modes_that_hold_the_books_say_so` is gone (the
|
||||||
|
modes work); `the_books_server_needs_a_root` runs everywhere.
|
||||||
|
`ledger-gui` `read_only` marks files read-only (not folders) on Windows.
|
||||||
|
|
||||||
|
**For P12 (mutation targets, none run, D-33):** `winsec::is_private`'s
|
||||||
|
three refusals (owner, missing DACL, an allow entry for anyone else) and the
|
||||||
|
ACE types it skips; `ipc::Endpoint::new`'s hash input (a mutant that drops
|
||||||
|
the lower-casing or the prefix strip splits one root into two pipes: found
|
||||||
|
only by two processes spelling the path differently); the Windows
|
||||||
|
`StdStream::read_some`'s deadline comparison and its `BROKEN_PIPE`
|
||||||
|
arm; `Listener`'s `IDLE_INSTANCES` pool (a mutant that makes it 1 passes
|
||||||
|
every test but `many_clients_at_once_all_connect`); `start.rs::host_env`'s
|
||||||
|
list. None of the Windows arms is reachable by `cargo mutants` on Linux:
|
||||||
|
they run on the Windows job only.
|
||||||
|
|
||||||
## P11a steps 2 and 3: update, rollback, the Linux packages (2026-10-29)
|
## P11a steps 2 and 3: update, rollback, the Linux packages (2026-10-29)
|
||||||
|
|
||||||
`wanderbooks update` and `rollback` (`ledger-host/src/update.rs`) are
|
`wanderbooks update` and `rollback` (`ledger-host/src/update.rs`) are
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
[tools]
|
[tools]
|
||||||
# Workspace toolchain (clippy and rustfmt for `just check`).
|
# Workspace toolchain (clippy and rustfmt for `just check`).
|
||||||
rust = [
|
rust = [
|
||||||
{ version = "1.99", components = "clippy,rustfmt" },
|
{ version = "1.99", components = "clippy,rustfmt", targets = "x86_64-pc-windows-msvc" },
|
||||||
# Fuzzing (cargo-fuzz) and Miri need nightly; pinned so runs repeat.
|
# Fuzzing (cargo-fuzz) and Miri need nightly; pinned so runs repeat.
|
||||||
{ version = "nightly-2026-10-01", components = "miri,rust-src" },
|
{ version = "nightly-2026-10-01", components = "miri,rust-src" },
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -1687,7 +1687,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
|
|||||||
5. **Files:** owns the Windows arms of the files above; touches `docs/packaging.md` (the inventory rows are struck as each lands), `docs/architecture.md`, `docs/testing.md`, `README.md` (the Windows caveat goes).
|
5. **Files:** owns the Windows arms of the files above; touches `docs/packaging.md` (the inventory rows are struck as each lands), `docs/architecture.md`, `docs/testing.md`, `README.md` (the Windows caveat goes).
|
||||||
6. **Tests and proofs:** the existing root, lock, lease and transport tests run on the Windows job (`cargo test --workspace` there); `a_second_user_cannot_open_the_pipe` (new, Windows-only, `crates/ledger-host`); no kernel, Kani or Lean change.
|
6. **Tests and proofs:** the existing root, lock, lease and transport tests run on the Windows job (`cargo test --workspace` there); `a_second_user_cannot_open_the_pipe` (new, Windows-only, `crates/ledger-host`); no kernel, Kani or Lean change.
|
||||||
7. **Done when:** (1) the `portable (windows-latest)` job is green with `experimental: false`; (2) `wanderbooks setup`, `books create`, `connect claude`, a bridge session, an upload and `wanderbooks gui` work on Windows from a source build (the engineer or the owner on a Windows machine; recorded in the As-built block); (3) `docs/packaging.md`'s Unix-only inventory is empty.
|
7. **Done when:** (1) the `portable (windows-latest)` job is green with `experimental: false`; (2) `wanderbooks setup`, `books create`, `connect claude`, a bridge session, an upload and `wanderbooks gui` work on Windows from a source build (the engineer or the owner on a Windows machine; recorded in the As-built block); (3) `docs/packaging.md`'s Unix-only inventory is empty.
|
||||||
8. **As built:** none yet.
|
8. **As built:** (2026-10-29, branch `P11c`; written on Linux, type-checked and linted for `x86_64-pc-windows-msvc` with the target pinned in `mise.toml`, **not run on Windows**: the `portable (windows-latest)` job's first run is the evidence for everything marked unverified here, and Done-when 2 needs a person on a Windows machine.) **Step 1, the data root:** `ledger-store/src/winsec.rs (new, Windows only)` holds the Windows FFI (dependency `windows-sys` 0.61, `cfg(windows)`): `set_owner_only` (a protected DACL of one allow entry for the account's SID, inherited by what a folder holds), `is_private` (owner is the account or Administrators; no allow entry for anyone but the account, SYSTEM or Administrators; a missing DACL is not private; any failure is "no"), `current_user`/`process_user`/`pid_is_us`, the shell's known folders (`profile_folder`, `local_app_data`), `rename_new` (`MoveFileExW` without the replace flag, write-through) and the pipe process-id calls. `root.rs`: `account_home` is the profile known folder; `platform_data_dir(home)` is the shell's `LocalAppData` for the account's own home and `home\AppData\Local` for any other (tests); `set_mode`, `check_private_dir` (now takes the path), `recorded_root` and `rename_new` have their Windows arms beside the Unix ones. **Step 2, locking:** `ledger-store/src/lock.rs (new)` is the one seam, on std's `try_lock`/`try_lock_shared` (flock; `LockFileEx`), replacing the only `libc::flock` (`ops.rs`); the plan's "LockFileEx behind lock.rs" is std's, so there is no second `unsafe` copy. **Step 3, the transport:** `ledger-mcp/src/ipc.rs (new)` is the seam for both families: `Endpoint`, `Listener`, `connect`, `connect_std` (a blocking client with a read limit; on Windows polled with `PeekNamedPipe`, since a pipe read cannot be cut short), `Peer` (`peer_is_us`, `peer_pid`), `stop_signals`, `split_accepted`/`split_connected`. A Windows pipe is `\\.\pipe\wanderbooks-<sha256 of the canonical, lower-cased run/ folder, 8 bytes>-<name>`, made with an owner-only DACL, remote clients refused, `first_pipe_instance` once, and 16 idle instances kept waiting (each its own task, replaced on connect) so a client never finds all busy; clients open with `SECURITY_IDENTIFICATION`; both ends check the other's process token user against their own SID. `local.rs`, `admin.rs`, `host.rs`, `opener.rs`, `bridge.rs`, `commands.rs` use `ipc` (their `peer_uid`/`peer_pid`/`own_uid` moved into it); `ledger-host/src/channel.rs (new)` gives reqwest the Unix socket or `windows_named_pipe`. `lib.rs` of `ledger-host` and `ledger-mcp` and `main.rs` of `ledger-cli` lost every `cfg(unix)` gate and the "comes with P11" stubs. `start.rs`: the Unix start (`setsid`, `umask`, systemd-run, `XDG_RUNTIME_DIR`) is `cfg(unix)`; the Windows start is `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB` (without the last when the job refuses it) with a fixed environment (`host_env`); `host.rs` told a replaced program by (size, last write) on Windows; `close_range` and `seccomp` stay Linux-only (D-32). Also: `backup.rs` stops a quiet git with `taskkill /T` and finds Git for Windows; `tokens.rs` and the viewer's thumbnail folder take the DACL; `.gitattributes` pins LF (autocrlf would break the byte-for-byte golden and hash tests on the runner). **Tests:** see `docs/testing.md` "P11c": `ledger-store/tests/windows_root.rs`, `ledger-mcp/src/ipc.rs` (4 on every OS), `ledger-host/tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`, Windows variants in `start.rs` and `host.rs`; the whole over-the-wire suite of `ledger-cli` now compiles for Windows and runs there through `common::clean_env`/`private_folder`/`default_root`/`mcp_http`. **Checked here:** `cargo check` and `cargo clippy -D warnings` for `x86_64-pc-windows-msvc` over the workspace and all targets (C dependencies stubbed with a no-op compiler/archiver shim, which `check` allows); on Linux fmt, clippy on the crates touched and their tests. **Not verifiable here, so the first CI run decides:** everything that runs on `windows-latest`, in particular the pipe code, the DACL reads, `taskkill`, the host's detached start and the over-the-wire suite. **Answers I chose** (alternatives in the report to the EM): the Windows ancestors check is none (CHOICE); the bridge's scripted-server tests (`against_a_server`) stay `cfg(unix)`; vault and TIN-registry files rely on inheritance from the private folder they sit in. **Second CI run (Windows):** `spec_vault` read every file under a book, lock files included; a held `LockFileEx` lock is mandatory, so the read failed (os error 33). The seam already locks dedicated files (`wanderbooks.lock`, `viewer.lock`, `server.lock`), never data files, so the lock-file approach was already in place; the tests now skip `*.lock` files (they hold no data). `update`/`rollback` and the host's update poll are `cfg(unix)` (P11a step 5 is Windows' updater). **Left open:** Done-when 2 (a person on Windows runs setup, crLine truncated
|
||||||
|
|
||||||
#### P12: max testing, again (D-33)
|
#### P12: max testing, again (D-33)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user