mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
This commit is contained in:
1 parent
5f560dfde0
commit
59eee050c6
10 files changed
+955
-47
No files matched your search
@@ -150,7 +150,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
|
||||
## 3. Agent control (automation and MCP)
|
||||
|
||||
- **Everything is reachable headlessly.** Every user-facing feature must be callable without the GUI: an engine API, then a tool in `crates/automation` (`pdfcraft-cli run` and MCP use the same table). A feature is not done until its tool exists and has an end-to-end test (`crates/automation/README.md`, "Adding a tool").
|
||||
- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control <file>`. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file. Never enable it by default, widen the bind address, or log the token.
|
||||
- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control <file>`. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file (mode 0600 on Unix; on Windows an access list of the user and SYSTEM only, which `pdfcraft_platform::owner_only` sets and checks, and which the CLI checks again before reading the token). Never enable it by default, widen the bind address, or log the token.
|
||||
- **The MCP server is opt-in.** Never make the app, the CLI or an installer start it, register it with an agent, or listen on a port by default. It runs only when a user (or an agent the user configured) launches `pdfcraft-cli mcp`. Any future network transport must be off by default, bound to loopback and authenticated with a token.
|
||||
- **Safe by default.** Tools must never discard unsaved work silently, must write atomically, must honour `--root`, and must return errors an agent can act on (say what was wrong and what is allowed).
|
||||
- **Track parity honestly.** Every user-facing feature has an entry in `parity/acrobat-features.toml`. Mark it `shipped` only when it works end to end and the entry cites a passing test (`evidence`); `cargo xtask parity` (part of `xtask ci`) rejects claims whose commands, tools or tests don't exist.
|
||||
|
||||
Generated
+1
@@ -4268,6 +4268,7 @@ dependencies = [
|
||||
"fmv-macos-events",
|
||||
"log",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-ui-egui",
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
|
||||
@@ -331,7 +331,7 @@ Edits stay in memory, undoable, until `doc_save`. Saving to the same file append
|
||||
|
||||
Start the desktop app with `pdfcraft --control ~/.pdfcraft-control.json` and an agent can see and operate the real interface: the widget tree with labels and positions (from the accessibility tree), clicks, typing, keys, commands, view options and screenshots. This is also off by default. It listens only on loopback, and every connection must present the random token written to that file, which only you can read.
|
||||
|
||||
Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, and on macOS, Linux and FreeBSD one that another user owns or can read or write. The app doesn't start if it can't write the file.
|
||||
Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, or one that another user owns, or that another user or a group such as Administrators can read or write: on macOS, Linux and FreeBSD by its owner and mode (the app writes it with mode 600), on Windows by its owner and access list (the app gives it one with only you and SYSTEM on it). The app doesn't start if it can't write the file. On Windows, cmd.exe doesn't expand `~`: use `%USERPROFILE%\.pdfcraft-control.json` there.
|
||||
|
||||
```sh
|
||||
pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect query=rotate # find widgets
|
||||
|
||||
+72
-15
@@ -50,18 +50,23 @@ pub(crate) fn ui(args: &[String]) -> Result<(), CliError> {
|
||||
/// Most a control file may hold. The app writes under 100 bytes.
|
||||
const CONTROL_FILE_MAX: u64 = 64 * 1024;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
const CONTROL_FILE_ADVICE: &str =
|
||||
"Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
|
||||
// cmd.exe doesn't expand `~` (#3922).
|
||||
#[cfg(windows)]
|
||||
const CONTROL_FILE_ADVICE: &str =
|
||||
r"Start the app with a control file in a folder only you can write, such as `pdfcraft --control %USERPROFILE%\.pdfcraft-control.json`";
|
||||
|
||||
/// Read the file written by `pdfcraft --control FILE`.
|
||||
///
|
||||
/// It names the port that receives the token and every command, typed text included, so it must
|
||||
/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
|
||||
/// first; the app then can't write it, and the CLI would talk to the other user's listener.
|
||||
/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
|
||||
/// else or open to other users (the app writes it with mode 600).
|
||||
/// Refused: a symbolic link, anything but a regular file, and a file owned by someone else or
|
||||
/// open to other users: on Unix by its owner and mode (the app writes it with mode 600), on
|
||||
/// Windows by its owner and access list (the app gives it one of its user and SYSTEM, #3922).
|
||||
fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
|
||||
use std::io::Read as _;
|
||||
let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
|
||||
if at_path.file_type().is_symlink() {
|
||||
return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
|
||||
@@ -69,21 +74,41 @@ fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
|
||||
if !at_path.is_file() {
|
||||
return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
|
||||
let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
|
||||
if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
|
||||
return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
|
||||
#[cfg(not(windows))]
|
||||
let text = {
|
||||
use std::io::Read as _;
|
||||
let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
// Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
|
||||
let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
|
||||
if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
|
||||
return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
|
||||
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
}
|
||||
if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
|
||||
let mut text = String::new();
|
||||
f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
|
||||
text
|
||||
};
|
||||
// Windows: the same owner and access check, on the security descriptor of the very handle the
|
||||
// contents are read through, so nothing swapped in at the path gets through (#3922). A file
|
||||
// planted by another user, or one other users can read, is refused; if the check itself can't
|
||||
// run, the file isn't trusted either.
|
||||
#[cfg(windows)]
|
||||
let text = {
|
||||
use pdfcraft_platform::owner_only;
|
||||
let me = owner_only::current_user_sid().map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
|
||||
let (sddl, bytes) = owner_only::read_with_sddl(std::path::Path::new(file), CONTROL_FILE_MAX)
|
||||
.map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
|
||||
if let Some(problem) = owner_only::foreign_access(&sddl, &me) {
|
||||
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
|
||||
String::from_utf8(bytes).map_err(|e| format!("{file}: {e}"))?
|
||||
};
|
||||
if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
|
||||
return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
|
||||
}
|
||||
@@ -112,6 +137,38 @@ fn current_uid() -> u32 {
|
||||
mod tests {
|
||||
use super::control_file_problem;
|
||||
|
||||
/// #3922: on Windows the CLI checks the control file's owner and access list. A file other
|
||||
/// users can read is refused; the same file restricted to its owner is accepted.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn on_windows_a_control_file_other_users_can_read_is_refused() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-control-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
|
||||
assert!(users.status.success());
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, r#"{"port":4242,"token":"t0ken"}"#).unwrap();
|
||||
let path = file.to_string_lossy().into_owned();
|
||||
let refused = super::read_control_file(&path).unwrap_err();
|
||||
assert!(refused.contains("another account") && refused.contains("%USERPROFILE%"), "{refused}");
|
||||
pdfcraft_platform::owner_only::restrict_to_owner(&file, &pdfcraft_platform::owner_only::current_user_sid().unwrap()).unwrap();
|
||||
assert_eq!(super::read_control_file(&path).unwrap()["token"].as_str(), Some("t0ken"));
|
||||
// The contents come with the check, capped: exactly the limit is read, one byte more is refused.
|
||||
let padded = |len: usize| {
|
||||
let json = r#"{"port":4242,"token":"t0ken"}"#;
|
||||
json.to_string() + &" ".repeat(len - json.len())
|
||||
};
|
||||
std::fs::write(&file, padded(64 * 1024)).unwrap();
|
||||
assert_eq!(super::read_control_file(&path).unwrap()["port"].as_u64(), Some(4242));
|
||||
std::fs::write(&file, padded(64 * 1024 + 1)).unwrap();
|
||||
assert!(super::read_control_file(&path).unwrap_err().contains("too large"));
|
||||
// Valid JSON once the byte is replaced by U+FFFD, so only a strict UTF-8 read refuses it.
|
||||
std::fs::write(&file, b"{\"port\":4242,\"token\":\"\xFF\"}").unwrap();
|
||||
assert!(super::read_control_file(&path).is_err(), "not UTF-8");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_file_must_be_yours_and_private() {
|
||||
// Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
|
||||
|
||||
@@ -86,6 +86,24 @@ fn write_private(path: &Path, text: &str) {
|
||||
opts.mode(0o600);
|
||||
}
|
||||
opts.open(path).unwrap().write_all(text.as_bytes()).unwrap();
|
||||
// On Windows the file inherits its folder's access list, as the app's would before it
|
||||
// restricts it (#3922).
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use pdfcraft_platform::owner_only;
|
||||
owner_only::restrict_to_owner(path, &owner_only::current_user_sid().unwrap()).unwrap();
|
||||
// The file the CLI is handed must already pass its check, or a refusal below would be
|
||||
// the helper's fault, not the CLI's.
|
||||
let sddl = owner_only::sddl_of(path).unwrap();
|
||||
let listing = |p: &Path| Command::new("icacls").arg(p).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
|
||||
let folder = path.parent().map(listing);
|
||||
assert_eq!(
|
||||
owner_only::foreign_access(&sddl, &owner_only::current_user_sid().unwrap()),
|
||||
None,
|
||||
"restricted file: {sddl}; icacls: {:?}; folder: {folder:?}",
|
||||
listing(path)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(control: &Path, method: &str) -> Output {
|
||||
|
||||
@@ -14,6 +14,8 @@ workspace = true
|
||||
pdfcraft-ui-egui = { workspace = true }
|
||||
# `build_info`: the version and compile-time build label for `--version` and the log.
|
||||
pdfcraft-engine = { workspace = true }
|
||||
# `owner_only`: the --control file is restricted to its user on Windows (#3922).
|
||||
pdfcraft-platform = { workspace = true }
|
||||
# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
|
||||
# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] }
|
||||
|
||||
+85
-29
@@ -514,7 +514,9 @@ fn open_control_channel(file: &str, endpoint: std::io::Result<pdfcraft_ui_egui::
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as ~/.pdfcraft-control.json");
|
||||
// cmd.exe doesn't expand `~` (#3922).
|
||||
let example = if cfg!(windows) { r"%USERPROFILE%\.pdfcraft-control.json" } else { "~/.pdfcraft-control.json" };
|
||||
let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as {example}");
|
||||
log::error!("{message}");
|
||||
Err(message)
|
||||
}
|
||||
@@ -523,15 +525,25 @@ fn open_control_channel(file: &str, endpoint: std::io::Result<pdfcraft_ui_egui::
|
||||
|
||||
/// Write the control endpoint so that only the current user can read the token.
|
||||
///
|
||||
/// The JSON goes to a new file next to `path`, created fresh (owner-only on Unix), which then
|
||||
/// replaces `path`. Opening `path` itself would follow a link planted there and truncate whatever
|
||||
/// it points at; a rename replaces the link and leaves its target alone. A reader polling for the
|
||||
/// file also never sees it half-written.
|
||||
/// The JSON goes to a new file, created fresh and owner-only, which then replaces `path`. On Unix
|
||||
/// the file sits next to `path` with mode 0600. On Windows it sits in a new folder next to `path`
|
||||
/// whose access list, of this user and SYSTEM only, it inherits when it is created, so no other
|
||||
/// account can open it at any time (#3922; review on #3956). Opening `path` itself would follow a
|
||||
/// link planted there and truncate whatever it points at; a rename replaces the link and leaves
|
||||
/// its target alone. A reader polling for the file also never sees it half-written.
|
||||
fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> {
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::io::ErrorKind;
|
||||
let json = serde_json::json!({ "port": port, "token": token, "pid": std::process::id() }).to_string();
|
||||
let path = std::path::Path::new(path);
|
||||
let name = path.file_name().ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "not a file name"))?;
|
||||
// Windows: the user's SID, read once for the private folder and for the file in it, since each
|
||||
// lookup starts `whoami` (review on #3956).
|
||||
#[cfg(windows)]
|
||||
let me = pdfcraft_platform::owner_only::current_user_sid()?;
|
||||
#[cfg(windows)]
|
||||
let restrict = |file: &std::path::Path| pdfcraft_platform::owner_only::restrict_to_owner(file, &me);
|
||||
#[cfg(not(windows))]
|
||||
let restrict = |_: &std::path::Path| Ok::<(), std::io::Error>(());
|
||||
// Unpredictable suffixes, so the name can't be planted in advance. `RandomState` is keyed from
|
||||
// the operating system's random source.
|
||||
let random = std::hash::RandomState::new();
|
||||
@@ -540,35 +552,79 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
|
||||
staged.push(name);
|
||||
staged.push(format!(".{:016x}.tmp", std::hash::BuildHasher::hash_one(&random, i)));
|
||||
let staged = path.with_file_name(staged);
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let file = match opts.open(&staged) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
|
||||
// Windows refuses `create_new` on a folder with "access denied"; the name is taken all
|
||||
// the same. A folder we can't write to, with nothing at the name, still fails here.
|
||||
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue,
|
||||
Err(e) => return Err(e),
|
||||
// Windows: a file created next to `path` would inherit that folder's access list, which may
|
||||
// let other users read it, and restricting it afterwards leaves a window in which one of
|
||||
// them opens a handle; Windows checks access only when a handle is opened, so a later
|
||||
// change of the list doesn't revoke it. The file is created in a private folder instead,
|
||||
// and that folder is removed once the file has left it.
|
||||
#[cfg(windows)]
|
||||
let (staging, staged) = {
|
||||
match pdfcraft_platform::owner_only::create_private_dir(&staged, &me) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
|
||||
// As for a file below: something at the name we may not open still takes it.
|
||||
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let file = staged.join(name);
|
||||
(staged, file)
|
||||
};
|
||||
// The block closes the file before it is renamed.
|
||||
let written = {
|
||||
let mut file = file;
|
||||
file.write_all(json.as_bytes())
|
||||
};
|
||||
let done = written.and_then(|()| std::fs::rename(&staged, path));
|
||||
if done.is_err() {
|
||||
let _ = std::fs::remove_file(&staged);
|
||||
let done = stage_control_file(&staged, path, &json, &restrict);
|
||||
#[cfg(windows)]
|
||||
let _ = std::fs::remove_dir(&staging);
|
||||
match done {
|
||||
Some(done) => return done,
|
||||
None => continue,
|
||||
}
|
||||
return done;
|
||||
}
|
||||
Err(std::io::Error::new(ErrorKind::AlreadyExists, "no free name for the control file's temporary copy"))
|
||||
}
|
||||
|
||||
/// Create `staged` fresh and owner-only, `restrict` it, write `json` to it and rename it to `path`.
|
||||
/// `None` when something already has the name, so the caller tries another. On any other failure
|
||||
/// the new file is removed and nothing is left at `path`'s side.
|
||||
fn stage_control_file(
|
||||
staged: &std::path::Path,
|
||||
path: &std::path::Path,
|
||||
json: &str,
|
||||
restrict: &dyn Fn(&std::path::Path) -> std::io::Result<()>,
|
||||
) -> Option<std::io::Result<()>> {
|
||||
use std::io::{ErrorKind, Write};
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let file = match opts.open(staged) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == ErrorKind::AlreadyExists => return None,
|
||||
// Windows refuses `create_new` on a folder with "access denied"; the name is taken all
|
||||
// the same. A folder we can't write to, with nothing at the name, still fails here.
|
||||
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => return None,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
// Windows: the file inherited the private folder's list. It also gets a list of its own, so it
|
||||
// stays private wherever the rename puts it, and that is checked before the token is written;
|
||||
// nothing is written if that fails (#3922). Unix has mode 0600 above, and `restrict` does nothing.
|
||||
if let Err(e) = restrict(staged) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(staged);
|
||||
return Some(Err(e));
|
||||
}
|
||||
// The block closes the file before it is renamed.
|
||||
let written = {
|
||||
let mut file = file;
|
||||
file.write_all(json.as_bytes())
|
||||
};
|
||||
let done = written.and_then(|()| std::fs::rename(staged, path));
|
||||
if done.is_err() {
|
||||
let _ = std::fs::remove_file(staged);
|
||||
}
|
||||
Some(done)
|
||||
}
|
||||
|
||||
/// How wgpu finds a GPU. Each choice yields to its wgpu environment variable.
|
||||
///
|
||||
/// - Draw on the integrated GPU unless `WGPU_POWER_PREF` says otherwise. A PDF viewer has no use
|
||||
|
||||
@@ -654,6 +654,42 @@ fn the_control_file_replaces_the_one_a_previous_run_left() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3922: a control file created in a folder that lets every user read what's in it is still
|
||||
/// readable by its user and SYSTEM only, like mode 0600 on Unix.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_control_file_is_owner_only_in_a_folder_other_users_can_read() {
|
||||
use pdfcraft_platform::owner_only;
|
||||
let dir = scratch();
|
||||
let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
|
||||
assert!(users.status.success());
|
||||
// What the folder hands down, for comparison: a plain file there is readable by Users.
|
||||
std::fs::write(dir.join("plain.txt"), "x").unwrap();
|
||||
let me = owner_only::current_user_sid().unwrap();
|
||||
assert!(owner_only::foreign_access(&owner_only::sddl_of(&dir.join("plain.txt")).unwrap(), &me).is_some());
|
||||
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
|
||||
let sddl = owner_only::sddl_of(&dir.join("ctl.json")).unwrap();
|
||||
assert_eq!(owner_only::foreign_access(&sddl, &me), None, "{sddl}");
|
||||
let (json, names) = written(&dir);
|
||||
assert_eq!(json["token"].as_str(), Some("t0ken"));
|
||||
assert_eq!(names, ["ctl.json", "plain.txt"], "no private staging folder left behind");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review on #3956: writing the control file reads the user's SID once, for both the private
|
||||
/// folder and the file in it, rather than starting `whoami` for each.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn writing_the_control_file_looks_up_the_users_sid_once() {
|
||||
use pdfcraft_platform::owner_only::sid_lookups_on_this_thread;
|
||||
let dir = scratch();
|
||||
let before = sid_lookups_on_this_thread();
|
||||
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
|
||||
assert_eq!(sid_lookups_on_this_thread().saturating_sub(before), 1, "whoami runs once per control file");
|
||||
assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
|
||||
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! pdfcraft-platform — L7 operating-system services shared by the desktop app (`ui-egui`) and the
|
||||
//! automation tools (`automation`), which sit on the same layer and can't depend on each other.
|
||||
//! automation tools (`automation`), which sit on the same layer and can't depend on each other,
|
||||
//! and by the two executables (`owner_only`, for the `--control` file).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod desktop_theme;
|
||||
pub mod owner_only;
|
||||
pub mod staging;
|
||||
pub mod zone;
|
||||
@@ -0,0 +1,736 @@
|
||||
//! Owner-only files on Windows (#3922): the `--control` token file must be readable by its user
|
||||
//! alone, as mode 0600 makes it on Unix.
|
||||
//!
|
||||
//! A file created in a folder inherits the folder's access list, and folders such as a new folder
|
||||
//! at a drive root, `C:\Users\Public` or `C:\ProgramData` let every signed-in user read what is
|
||||
//! created in them. [`restrict_to_owner`] replaces the whole list with the current user and
|
||||
//! SYSTEM only, makes that user the owner, and checks the result. [`create_private_dir`] does the
|
||||
//! same for a new folder whose list the files created in it inherit, so a file can be private from
|
||||
//! the moment it exists. [`read_with_sddl`] reads a file and
|
||||
//! its security descriptor through one handle. [`foreign_access`] reads the descriptor in SDDL
|
||||
//! form, which names accounts by SID and so reads the same in every Windows language, and says why
|
||||
//! another user could have written or could read the file. They run the tools Windows ships rather
|
||||
//! than calling the security API directly, which would need `unsafe`.
|
||||
|
||||
/// Why the file whose security descriptor is `sddl` (`O:` owner and `D:` access list, in SDDL or
|
||||
/// as [`read_with_sddl`] writes it) can't be trusted as user `me`'s own: it is owned by someone else, it has no
|
||||
/// access list (everyone may use it), or it lets an account other than `me` or SYSTEM use it.
|
||||
/// Accounts are compared by SID; `None` means it is `me`'s alone.
|
||||
///
|
||||
/// Stricter than OpenSSH's rule for private keys, which also lets Administrators in: an entry for
|
||||
/// the Administrators group would let another administrator read the token without taking the
|
||||
/// file over. [`restrict_to_owner`] never grants it, so the app's own file always passes; a file
|
||||
/// that inherited it from a user's folder is refused.
|
||||
pub fn foreign_access(sddl: &str, me: &str) -> Option<String> {
|
||||
let me = me.trim();
|
||||
let owner = section(sddl, "O:");
|
||||
match owner {
|
||||
Some(o) if same_account(o, me) => {}
|
||||
Some(o) => return Some(format!("it is owned by {}", another_account(o, ""))),
|
||||
None => return Some("its owner can't be read".into()),
|
||||
}
|
||||
let Some(dacl) = section(sddl, "D:") else {
|
||||
return Some("it has no access list, so every user may read it".into());
|
||||
};
|
||||
if dacl.starts_with("NO_ACCESS_CONTROL") {
|
||||
return Some("it has no access control, so every user may read it".into());
|
||||
}
|
||||
for ace in dacl.split('(').skip(1) {
|
||||
let ace = ace.split(')').next().unwrap_or_default();
|
||||
let parts: Vec<&str> = ace.split(';').collect();
|
||||
// `type;flags;rights;object guid;inherited object guid;account SID[;(condition)]`.
|
||||
let (Some(kind), Some(flags)) = (parts.first(), parts.get(1)) else { continue };
|
||||
// Deny entries take access away, and inherit-only entries apply to children, not the file.
|
||||
let allows = matches!(*kind, "A" | "OA" | "XA" | "ZA");
|
||||
if !allows || flags.contains("IO") {
|
||||
continue;
|
||||
}
|
||||
// An entry that grants access but names no account can't be shown to be the user's.
|
||||
let Some(sid) = parts.get(5).filter(|s| !s.is_empty()) else {
|
||||
return Some(format!("its access list has an entry that can't be read ({ace})"));
|
||||
};
|
||||
if !(same_account(sid, me) || matches!(*sid, "SY" | "S-1-5-18" | "OW" | "S-1-3-4")) {
|
||||
return Some(another_account(sid, "has access to it"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `another account (<sid>) <rest>`, with the account's name before its SID when it is one of the
|
||||
/// well-known accounts a control file can carry, such as `another account, Administrators
|
||||
/// (S-1-5-32-544), has access to it` (review on #3956). The SID stays, so the message reads the
|
||||
/// same in every Windows language and can still be looked up.
|
||||
fn another_account(sid: &str, rest: &str) -> String {
|
||||
match (well_known_name(sid), rest.is_empty()) {
|
||||
(Some(name), true) => format!("another account, {name} ({sid})"),
|
||||
(Some(name), false) => format!("another account, {name} ({sid}), {rest}"),
|
||||
(None, true) => format!("another account ({sid})"),
|
||||
(None, false) => format!("another account ({sid}) {rest}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The English name Windows gives a well-known SID that can be on a file's access list or own it:
|
||||
/// those a folder hands down at a drive root, in `C:\Users\Public` and in a user's profile, and
|
||||
/// Everyone. The SIDs and names are Microsoft's public list of well-known SIDs
|
||||
/// (<https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids>); the names are
|
||||
/// the ones `icacls` shows, without the `BUILTIN\` or `NT AUTHORITY\` prefix. Any other SID,
|
||||
/// a user's or a domain group's, has no fixed name and gets `None`.
|
||||
fn well_known_name(sid: &str) -> Option<&'static str> {
|
||||
const NAMES: [(&str, &str); 8] = [
|
||||
("S-1-1-0", "Everyone"),
|
||||
("S-1-5-3", "BATCH"),
|
||||
("S-1-5-4", "INTERACTIVE"),
|
||||
("S-1-5-6", "SERVICE"),
|
||||
("S-1-5-11", "Authenticated Users"),
|
||||
("S-1-5-18", "SYSTEM"),
|
||||
("S-1-5-32-544", "Administrators"),
|
||||
("S-1-5-32-545", "Users"),
|
||||
];
|
||||
NAMES.iter().find(|(known, _)| known.eq_ignore_ascii_case(sid)).map(|&(_, name)| name)
|
||||
}
|
||||
|
||||
/// `section(sddl, "O:")` is the owner, `"D:"` the access list: the text after the tag up to the
|
||||
/// next top-level tag.
|
||||
fn section<'a>(sddl: &'a str, tag: &str) -> Option<&'a str> {
|
||||
let start = sddl.find(tag)?.checked_add(tag.len())?;
|
||||
let rest = sddl.get(start..)?;
|
||||
// The owner and group are bare SIDs; the access lists run until the next `X:` tag outside
|
||||
// parentheses.
|
||||
let mut depth = 0usize;
|
||||
let mut end = rest.len();
|
||||
for (i, c) in rest.char_indices() {
|
||||
match c {
|
||||
'(' => depth = depth.saturating_add(1),
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
':' if depth == 0 && i > 0 => {
|
||||
// The tag is the one letter before this colon.
|
||||
end = rest.get(..i).and_then(|s| s.char_indices().last()).map_or(i, |(j, _)| j);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
rest.get(..end).map(str::trim)
|
||||
}
|
||||
|
||||
/// Whether account `a` names user SID `me`. `OW` (owner rights) and `CO` are not users. An alias
|
||||
/// is never taken for the user: [`read_with_sddl`] gives every account as a full SID, because
|
||||
/// Windows' own SDDL writes some users by alias (the built-in Administrator as `LA`, whose SID
|
||||
/// depends on the machine).
|
||||
fn same_account(a: &str, me: &str) -> bool {
|
||||
a.eq_ignore_ascii_case(me)
|
||||
}
|
||||
|
||||
/// The tools Windows ships that these checks run, by their path under `%SystemRoot%\System32`.
|
||||
#[cfg(windows)]
|
||||
const WHOAMI: &str = "whoami.exe";
|
||||
#[cfg(windows)]
|
||||
const POWERSHELL: &str = r"WindowsPowerShell\v1.0\powershell.exe";
|
||||
#[cfg(windows)]
|
||||
const ICACLS: &str = "icacls.exe";
|
||||
|
||||
/// A tool Windows ships, started by its full path (review on #3956). Started by bare name, a
|
||||
/// `whoami.exe`, `powershell.exe` or `icacls.exe` found first (next to the app, or earlier on the
|
||||
/// search path) would answer instead, and `whoami`'s answer is what every check here trusts.
|
||||
#[cfg(windows)]
|
||||
fn system_tool(relative: &str) -> std::process::Command {
|
||||
let root = std::env::var_os("SystemRoot").map_or_else(|| std::path::PathBuf::from(r"C:\Windows"), std::path::PathBuf::from);
|
||||
std::process::Command::new(root.join("System32").join(relative))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
thread_local! {
|
||||
/// How many times [`current_user_sid`] ran on this thread, for [`sid_lookups_on_this_thread`].
|
||||
static SID_LOOKUPS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many times [`current_user_sid`] has started `whoami` on the calling thread. Each lookup
|
||||
/// starts a process, so a test can check that a caller reads the SID once and passes it on
|
||||
/// (review on #3956). Per thread, so tests running in parallel don't count each other's.
|
||||
#[cfg(windows)]
|
||||
#[doc(hidden)]
|
||||
pub fn sid_lookups_on_this_thread() -> usize {
|
||||
SID_LOOKUPS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// The current user's SID (`whoami /user`). It starts a process, so a caller that needs it for
|
||||
/// several steps reads it once and passes it to each.
|
||||
#[cfg(windows)]
|
||||
pub fn current_user_sid() -> std::io::Result<String> {
|
||||
SID_LOOKUPS.with(|n| n.set(n.get().saturating_add(1)));
|
||||
let out = system_tool(WHOAMI).args(["/user", "/fo", "csv", "/nh"]).output()?;
|
||||
if !out.status.success() {
|
||||
return Err(std::io::Error::other("whoami /user failed"));
|
||||
}
|
||||
sid_from_whoami(&String::from_utf8_lossy(&out.stdout)).ok_or_else(|| std::io::Error::other("whoami /user printed no SID"))
|
||||
}
|
||||
|
||||
#[cfg(any(test, windows))]
|
||||
/// The SID in `whoami /user /fo csv /nh` output, `"DOMAIN\user","S-1-5-21-…"`: the last quoted
|
||||
/// field, when it is one. Anything else (an error message, nothing) gives `None`.
|
||||
fn sid_from_whoami(text: &str) -> Option<String> {
|
||||
let last = text.trim().rsplit(',').next()?.trim().trim_matches('"');
|
||||
let digits = last.strip_prefix("S-1-")?;
|
||||
// Every part after `S-1-` is a number; an empty remainder is one empty part, so it fails too.
|
||||
digits.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())).then(|| last.to_string())
|
||||
}
|
||||
|
||||
/// Replace `path`'s access list with one that only user `me` (the current user's SID, from
|
||||
/// [`current_user_sid`], which the caller reads once for every step) and SYSTEM are on, then make
|
||||
/// that user its owner (`icacls /setowner`), then read the file's descriptor back and check it with
|
||||
/// [`foreign_access`]. The whole list is replaced, not just what was inherited: on the
|
||||
/// windows-latest runner a new file in the temp folder carries an Administrators entry that `icacls
|
||||
/// /inheritance:r` leaves in place. The owner is set because a file an elevated administrator creates
|
||||
/// is owned by the Administrators group, not by the user. The list comes first: its owner may always
|
||||
/// replace it, while setting the owner needs the Full control it grants (a folder may give Modify).
|
||||
#[cfg(windows)]
|
||||
pub fn restrict_to_owner(path: &std::path::Path, me: &str) -> std::io::Result<()> {
|
||||
set_access_list(path, me, Kind::File)?;
|
||||
icacls(path, &["/setowner".into(), format!("*{me}")])?;
|
||||
still_foreign(&sddl_of(path)?, me)
|
||||
}
|
||||
|
||||
/// Create the folder `dir`, which must not exist yet, and give it an access list of user `me` (as
|
||||
/// for [`restrict_to_owner`]) and SYSTEM only, which every file created in it inherits; then make
|
||||
/// that user its owner and check the result as [`restrict_to_owner`] does. A file created in it is
|
||||
/// private from the moment it exists. Restricting a file after creating it leaves a window in which another user
|
||||
/// whom the parent folder lets read it can open a handle, and Windows checks access only when a
|
||||
/// handle is opened, so a later change of the list doesn't revoke it (review on #3956). A handle
|
||||
/// on the folder itself, opened during the folder's own window, gives no access to the files
|
||||
/// created in it later. The folder is removed again when it can't be restricted.
|
||||
#[cfg(windows)]
|
||||
pub fn create_private_dir(dir: &std::path::Path, me: &str) -> std::io::Result<()> {
|
||||
std::fs::create_dir(dir)?;
|
||||
fn restrict(dir: &std::path::Path, me: &str) -> std::io::Result<()> {
|
||||
set_access_list(dir, me, Kind::Folder)?;
|
||||
icacls(dir, &["/setowner".into(), format!("*{me}")])?;
|
||||
still_foreign(&sddl_of(dir)?, me)
|
||||
}
|
||||
let restricted = restrict(dir, me);
|
||||
if restricted.is_err() {
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
restricted
|
||||
}
|
||||
|
||||
/// `Err` naming the problem when the descriptor read back after restricting still isn't `me`'s
|
||||
/// alone, so a file that only looks restricted is never used as one.
|
||||
#[cfg(any(test, windows))]
|
||||
fn still_foreign(sddl: &str, me: &str) -> std::io::Result<()> {
|
||||
match foreign_access(sddl, me) {
|
||||
None => Ok(()),
|
||||
Some(problem) => Err(std::io::Error::other(format!("the file could not be restricted to its owner: {problem}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`set_access_list`] restricts: a file, or a folder whose entries the files and folders
|
||||
/// created in it inherit.
|
||||
#[cfg(windows)]
|
||||
#[derive(Clone, Copy)]
|
||||
enum Kind {
|
||||
File,
|
||||
Folder,
|
||||
}
|
||||
|
||||
/// Set `path`'s access list to Full control for `me` and SYSTEM and nothing else, protected from
|
||||
/// inheritance; on a folder, what is created in it inherits the two entries. Only the list is
|
||||
/// written, not the owner. `me` and the path reach PowerShell through environment variables, never
|
||||
/// as script text.
|
||||
#[cfg(windows)]
|
||||
fn set_access_list(path: &std::path::Path, me: &str, kind: Kind) -> std::io::Result<()> {
|
||||
// No double quotes: the command line would strip them.
|
||||
const FILE: &str = "$s = New-Object Security.AccessControl.FileSecurity; \
|
||||
$s.SetSecurityDescriptorSddlForm('D:P(A;;FA;;;' + $env:PDFCRAFT_ACL_USER + ')(A;;FA;;;SY)', 'Access'); \
|
||||
[IO.File]::SetAccessControl($env:PDFCRAFT_ACL_PATH, $s)";
|
||||
const FOLDER: &str = "$s = New-Object Security.AccessControl.DirectorySecurity; \
|
||||
$s.SetSecurityDescriptorSddlForm('D:P(A;OICI;FA;;;' + $env:PDFCRAFT_ACL_USER + ')(A;OICI;FA;;;SY)', 'Access'); \
|
||||
[IO.Directory]::SetAccessControl($env:PDFCRAFT_ACL_PATH, $s)";
|
||||
let script = match kind {
|
||||
Kind::File => FILE,
|
||||
Kind::Folder => FOLDER,
|
||||
};
|
||||
let out = system_tool(POWERSHELL)
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", script])
|
||||
.env("PDFCRAFT_ACL_PATH", path)
|
||||
.env("PDFCRAFT_ACL_USER", me)
|
||||
.output()?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other(format!("PowerShell could not restrict the file to its owner: {}", String::from_utf8_lossy(&out.stderr).trim())))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn icacls(path: &std::path::Path, args: &[String]) -> std::io::Result<()> {
|
||||
let out = system_tool(ICACLS).arg(path).args(args).output()?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other(format!("icacls could not restrict the file to its owner: {}", String::from_utf8_lossy(&out.stderr).trim())))
|
||||
}
|
||||
}
|
||||
|
||||
/// PowerShell that turns `$raw`, a `RawSecurityDescriptor`, into `$sddl` as [`read_with_sddl`]
|
||||
/// describes it. No double quotes: the command line would strip them. An entry with no account (a
|
||||
/// custom entry) is written with none, and `foreign_access` refuses it.
|
||||
#[cfg(windows)]
|
||||
macro_rules! describe_raw {
|
||||
() => {
|
||||
"$sddl = 'O:' + $raw.Owner.Value; if ($raw.DiscretionaryAcl -ne $null) { $sddl += 'D:'; foreach ($a in $raw.DiscretionaryAcl) { $t = 'A'; if ([string]$a.AceType -like 'AccessDenied*') { $t = 'D' }; $f = ''; if ($a.AceFlags.HasFlag([Security.AccessControl.AceFlags]::InheritOnly)) { $f = 'IO' }; $sid = ''; if ($a.SecurityIdentifier) { $sid = $a.SecurityIdentifier.Value }; $sddl += '(' + $t + ';' + $f + ';' + $a.AccessMask + ';;;' + $sid + ')' } }; "
|
||||
};
|
||||
}
|
||||
|
||||
/// Open `path` shared for reading only, so that while it is open it can't be replaced, renamed or
|
||||
/// written, and read through that one handle both its security descriptor (owner and access list)
|
||||
/// and its first `max + 1` bytes. The descriptor checked is the one of the handle the bytes come
|
||||
/// from, so there is no moment at which the path could name a different file (a time-of-check to
|
||||
/// time-of-use race). The path reaches PowerShell through an environment variable, never as script
|
||||
/// text.
|
||||
///
|
||||
/// The descriptor is written in SDDL's shape but with every account as a full SID, read from the
|
||||
/// binary descriptor (`RawSecurityDescriptor`): `O:<owner>D:(<type>;<flags>;<rights>;;;<sid>)…`,
|
||||
/// where the type is `D` for a deny entry and `A` for any other, and the flags are `IO` for an
|
||||
/// inherit-only entry. Windows' own SDDL writes some accounts by alias, and the built-in
|
||||
/// Administrator's alias `LA` stands for a SID that depends on the machine.
|
||||
#[cfg(windows)]
|
||||
pub fn read_with_sddl(path: &std::path::Path, max: u64) -> std::io::Result<(String, Vec<u8>)> {
|
||||
const SCRIPT: &str = concat!(
|
||||
"$fs = [IO.File]::Open($env:PDFCRAFT_ACL_PATH, 'Open', 'Read', 'Read'); try { $raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @($fs.GetAccessControl().GetSecurityDescriptorBinaryForm(), 0); ",
|
||||
describe_raw!(),
|
||||
"$max = [int]$env:PDFCRAFT_ACL_MAX + 1; $buf = New-Object byte[] $max; $total = 0; while ($total -lt $max) { $n = $fs.Read($buf, $total, $max - $total); if ($n -le 0) { break }; $total += $n }; [Console]::Out.Write($sddl + [char]10 + [BitConverter]::ToString($buf, 0, $total)) } finally { $fs.Dispose() }"
|
||||
);
|
||||
// Bounded so the script's `[int]` and buffer stay small; a control file is far below this.
|
||||
let max = max.min(1 << 20);
|
||||
let out = system_tool(POWERSHELL)
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT])
|
||||
.env("PDFCRAFT_ACL_PATH", path)
|
||||
.env("PDFCRAFT_ACL_MAX", max.to_string())
|
||||
.output()?;
|
||||
let read = if out.status.success() { sddl_and_bytes(&String::from_utf8_lossy(&out.stdout)) } else { None };
|
||||
read.ok_or_else(|| std::io::Error::other("PowerShell could not read the file with its owner and access list"))
|
||||
}
|
||||
|
||||
/// The security descriptor of `path` as [`read_with_sddl`] writes it, read by name without opening
|
||||
/// the file's contents, so it works on a file that is open for writing (the app restricts its
|
||||
/// control file before writing the token through the handle it created).
|
||||
#[cfg(windows)]
|
||||
pub fn sddl_of(path: &std::path::Path) -> std::io::Result<String> {
|
||||
const SCRIPT: &str = concat!(
|
||||
// Stop: a failed read (a missing file) must end the script with an error, not print `O:`.
|
||||
"$ErrorActionPreference = 'Stop'; $raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @([IO.File]::GetAccessControl($env:PDFCRAFT_ACL_PATH).GetSecurityDescriptorBinaryForm(), 0); ",
|
||||
describe_raw!(),
|
||||
"[Console]::Out.Write($sddl + [char]10)"
|
||||
);
|
||||
let out = system_tool(POWERSHELL).args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT]).env("PDFCRAFT_ACL_PATH", path).output()?;
|
||||
let read = if out.status.success() { sddl_and_bytes(&String::from_utf8_lossy(&out.stdout)) } else { None };
|
||||
read.map(|(sddl, _)| sddl).ok_or_else(|| std::io::Error::other("PowerShell could not read the file's owner and access list"))
|
||||
}
|
||||
|
||||
/// [`read_with_sddl`]'s output: the SDDL, a newline, then the bytes as `[BitConverter]::ToString`
|
||||
/// prints them (`7B-22-…`, empty for none). Anything else gives `None`.
|
||||
#[cfg(any(test, windows))]
|
||||
fn sddl_and_bytes(text: &str) -> Option<(String, Vec<u8>)> {
|
||||
let (sddl, hex) = text.split_once('\n')?;
|
||||
let sddl = sddl.trim();
|
||||
if !sddl.contains("O:") {
|
||||
return None;
|
||||
}
|
||||
let hex = hex.trim();
|
||||
if hex.is_empty() {
|
||||
return Some((sddl.to_string(), Vec::new()));
|
||||
}
|
||||
// Two hex digits each; `from_str_radix` alone would also take a sign, as in `+7`.
|
||||
let byte = |pair: &str| (pair.len() == 2 && pair.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u8::from_str_radix(pair, 16).ok()).flatten();
|
||||
let bytes = hex.split('-').map(byte).collect::<Option<Vec<u8>>>()?;
|
||||
Some((sddl.to_string(), bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ME: &str = "S-1-5-21-1-2-3-1001";
|
||||
|
||||
/// The SID of the user running the tests, as `write_control_file` reads it once and passes on.
|
||||
#[cfg(windows)]
|
||||
fn user() -> String {
|
||||
current_user_sid().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_users_own_file_with_system_is_trusted_and_administrators_are_not() {
|
||||
assert_eq!(foreign_access(&format!("O:{ME}G:{ME}D:P(A;;FA;;;{ME})(A;;FA;;;SY)"), ME), None);
|
||||
assert_eq!(foreign_access(&format!("O:{ME}D:(A;;2032127;;;S-1-5-18)(A;;2032127;;;{ME})"), ME), None);
|
||||
// Another administrator could read it through the Administrators group, by alias or by SID.
|
||||
for admins in ["BA", "S-1-5-32-544"] {
|
||||
let sddl = format!("O:{ME}D:PAI(A;;FA;;;SY)(A;;FR;;;{admins})(A;;FA;;;{ME})");
|
||||
assert!(foreign_access(&sddl, ME).is_some_and(|p| p.contains(admins)), "{sddl}");
|
||||
}
|
||||
// Inherit-only entries and deny entries don't grant access to the file itself.
|
||||
assert_eq!(foreign_access(&format!("O:{ME}D:(A;OICIIO;GA;;;CO)(D;;FA;;;BU)(A;;FA;;;{ME})"), ME), None);
|
||||
}
|
||||
|
||||
/// Review on #3956: each tool is started by its full path in System32, never looked up by
|
||||
/// name.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_tools_are_started_from_system32() {
|
||||
for tool in [WHOAMI, POWERSHELL, ICACLS] {
|
||||
let command = system_tool(tool);
|
||||
let path = std::path::Path::new(command.get_program());
|
||||
assert!(path.is_absolute() && path.is_file(), "{path:?}");
|
||||
assert!(path.to_string_lossy().to_ascii_lowercase().contains(r"\system32\"), "{path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_owners_readers_and_missing_lists_are_refused() {
|
||||
let other = "S-1-5-21-1-2-3-1002";
|
||||
assert!(foreign_access(&format!("O:{other}D:(A;;FA;;;{ME})"), ME).unwrap().contains("owned by another account"));
|
||||
// Users (BU) and Authenticated Users (AU), as a folder at a drive root passes on.
|
||||
assert!(foreign_access(&format!("O:{ME}D:AI(A;ID;FA;;;{ME})(A;ID;0x1301bf;;;AU)"), ME).unwrap().contains("AU"));
|
||||
assert!(foreign_access(&format!("O:{ME}D:(A;;FR;;;BU)(A;;FA;;;{ME})"), ME).unwrap().contains("BU"));
|
||||
assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;{other})"), ME).is_some());
|
||||
assert!(foreign_access(&format!("O:{ME}G:SY"), ME).unwrap().contains("no access list"));
|
||||
assert!(foreign_access(&format!("O:{ME}D:NO_ACCESS_CONTROL"), ME).is_some());
|
||||
assert!(foreign_access("D:(A;;FA;;;SY)", ME).unwrap().contains("owner"));
|
||||
}
|
||||
|
||||
/// A conditional entry can hold a `:` in a string; the access list doesn't end there, so the
|
||||
/// entry after it is still checked.
|
||||
#[test]
|
||||
fn a_colon_inside_an_entry_does_not_end_the_access_list() {
|
||||
let sddl = format!(r#"O:{ME}D:(XA;;FA;;;{ME};(@User.dept == "a:b"))(A;;FR;;;BU)"#);
|
||||
assert!(foreign_access(&sddl, ME).is_some_and(|p| p.contains("BU")), "{sddl}");
|
||||
}
|
||||
|
||||
/// A conditional entry for the user is the user's; one that grants access but whose account
|
||||
/// can't be read is refused rather than skipped.
|
||||
#[test]
|
||||
fn conditional_and_malformed_entries() {
|
||||
assert_eq!(foreign_access(&format!(r#"O:{ME}D:(XA;;FA;;;{ME};(@User.dept == "x"))"#), ME), None);
|
||||
assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;{ME})(A;;FR)"), ME).is_some_and(|p| p.contains("can't be read")));
|
||||
assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;)"), ME).is_some_and(|p| p.contains("can't be read")));
|
||||
// A conditional entry grants access like a plain one: another account on it is refused.
|
||||
let other = format!(r#"O:{ME}D:(A;;FA;;;{ME})(XA;;FR;;;BU;(@User.dept == "x"))"#);
|
||||
assert!(foreign_access(&other, ME).is_some_and(|p| p.contains("BU")), "{other}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sid_is_read_from_whoamis_output_and_nothing_else() {
|
||||
assert_eq!(sid_from_whoami("\"desk\\ada\",\"S-1-5-21-1-2-3-1001\"\r\n").as_deref(), Some("S-1-5-21-1-2-3-1001"));
|
||||
for bad in [
|
||||
"",
|
||||
"ERROR: Access is denied.",
|
||||
"\"desk\\ada\",\"\"",
|
||||
"\"desk\\ada\",\"S-1-\"",
|
||||
"\"S-1-5-x\"",
|
||||
"\"desk\\ada\"",
|
||||
"\"desk\\ada\",\"1-5-21-7\"",
|
||||
] {
|
||||
assert_eq!(sid_from_whoami(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The real tools: a file created in a folder that lets Users read what's in it inherits that
|
||||
/// access; after `restrict_to_owner` only the current user and SYSTEM are left.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_file_in_a_shared_folder_is_restricted_to_its_owner() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ok = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
|
||||
assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stderr));
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
// Users inherit read access, so the file is refused. (Run elevated, the file is also owned
|
||||
// by Administrators, which is the reason given first.)
|
||||
let before = sddl_of(&file).unwrap();
|
||||
assert!(before.contains(";BU)") || before.contains(";S-1-5-32-545)"), "Users inherit read access: {before}");
|
||||
assert!(foreign_access(&before, &me).is_some(), "{before}");
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
let sddl = sddl_of(&file).unwrap();
|
||||
assert_eq!(section(&sddl, "O:"), Some(me.as_str()), "{sddl}");
|
||||
assert_eq!(foreign_access(&sddl, &me), None, "{sddl}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review on #3956: a file created in a private folder inside a folder that lets Users read
|
||||
/// what's in it is the user's alone from the moment it exists, before anything restricts it.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_file_created_in_a_private_folder_is_private_from_the_start() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-private-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let ok = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
|
||||
assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stderr));
|
||||
let private = dir.join("staging");
|
||||
create_private_dir(&private, &user()).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
let folder = sddl_of(&private).unwrap();
|
||||
assert_eq!((section(&folder, "O:"), foreign_access(&folder, &me)), (Some(me.as_str()), None), "{folder}");
|
||||
let file = private.join("ctl.json");
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(&file).unwrap();
|
||||
let sddl = sddl_of(&file).unwrap();
|
||||
assert!(!sddl.contains(";BU)") && !sddl.contains(";S-1-5-32-545)"), "Users must not inherit read access: {sddl}");
|
||||
assert_eq!(foreign_access(&sddl, &me), None, "{sddl}");
|
||||
// The name is taken: the folder isn't reused.
|
||||
assert!(create_private_dir(&private, &user()).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A folder may give its user Modify but not Full control, so a file created there doesn't let
|
||||
/// its owner change the owner (no `WRITE_OWNER`). An owner can always replace the access list,
|
||||
/// though, and the user's Full control on it then allows setting the owner.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_file_in_a_folder_granting_only_modify_is_still_restricted() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-modify-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
let modify = std::process::Command::new("icacls")
|
||||
.arg(&dir)
|
||||
.args(["/inheritance:r", "/grant:r"])
|
||||
.arg(format!("*{me}:(OI)(CI)M"))
|
||||
.arg("*S-1-5-18:(OI)(CI)F")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(modify.status.success(), "{}", String::from_utf8_lossy(&modify.stderr));
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
let sddl = sddl_of(&file).unwrap();
|
||||
assert_eq!((section(&sddl, "O:"), foreign_access(&sddl, &me)), (Some(me.as_str()), None), "{sddl}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A null access list (`NO_ACCESS_CONTROL`) lets every account use the file, so it is refused.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_file_with_no_access_list_is_refused() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-null-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
// A null access list lets everyone in; the user, as owner, may set one.
|
||||
let null = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"$s = New-Object Security.AccessControl.FileSecurity; $s.SetSecurityDescriptorSddlForm('D:NO_ACCESS_CONTROL', 'Access'); [IO.File]::SetAccessControl($env:PDFCRAFT_NULL_PATH, $s)",
|
||||
])
|
||||
.env("PDFCRAFT_NULL_PATH", &file)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(null.status.success(), "{}", String::from_utf8_lossy(&null.stderr));
|
||||
let (sddl, _) = read_with_sddl(&file, 64).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
assert!(foreign_access(&sddl, &me).is_some_and(|p| p.contains("no access list")), "{sddl}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// An `OWNER RIGHTS` entry replaces what an owner may do implicitly. Granting only *write
|
||||
/// owner* that way, the access list can't be replaced although the owner can still be set: the
|
||||
/// file isn't taken as restricted.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_file_whose_access_list_cant_be_replaced_is_not_restricted() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-ow-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
// The whole list is written, as `restrict_to_owner` does: `icacls /inheritance:r` leaves
|
||||
// the Administrators entry the windows-latest runner gives a new file, and with it an
|
||||
// elevated administrator could replace the list after all.
|
||||
let rights = std::process::Command::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"$s = New-Object Security.AccessControl.FileSecurity; $s.SetSecurityDescriptorSddlForm('D:P(A;;FR;;;' + $env:PDFCRAFT_OW_USER + ')(A;;WO;;;OW)', 'Access'); [IO.File]::SetAccessControl($env:PDFCRAFT_OW_PATH, $s)",
|
||||
])
|
||||
.env("PDFCRAFT_OW_PATH", &file)
|
||||
.env("PDFCRAFT_OW_USER", &me)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(rights.status.success(), "{}", String::from_utf8_lossy(&rights.stderr));
|
||||
// Nothing but the two entries: no Administrators entry for the elevated runner to use.
|
||||
let sddl = sddl_of(&file).unwrap();
|
||||
assert_eq!(section(&sddl, "D:"), Some(format!("(A;;524288;;;S-1-3-4)(A;;1179785;;;{me})").as_str()), "{sddl}");
|
||||
assert!(restrict_to_owner(&file, &user()).is_err(), "the access list could not be replaced");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// When the tools fail, the functions say so: nothing is taken as restricted or as checked.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn tool_failures_are_errors() {
|
||||
let missing = std::env::temp_dir().join(format!("pdfcraft-owner-only-missing-{}", std::process::id())).join("none.json");
|
||||
assert!(restrict_to_owner(&missing, &user()).is_err(), "icacls on a missing file");
|
||||
assert!(sddl_of(&missing).is_err(), "Get-Acl on a missing file");
|
||||
let me = current_user_sid().unwrap();
|
||||
assert!(me.starts_with("S-1-5-") && me.split('-').count() >= 4, "{me}");
|
||||
assert!(!me.contains('"') && !me.contains('\\'), "the SID alone, not the account name: {me}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_descriptor_and_bytes_are_read_from_the_scripts_output_and_nothing_else() {
|
||||
let sddl = format!("O:{ME}D:P(A;;FA;;;{ME})");
|
||||
assert_eq!(sddl_and_bytes(&format!("{sddl}\n7B-0A-ff\r\n")), Some((sddl.clone(), vec![0x7B, 0x0A, 0xFF])));
|
||||
assert_eq!(sddl_and_bytes(&format!("{sddl}\n")), Some((sddl.clone(), Vec::new())));
|
||||
for bad in [
|
||||
String::new(),
|
||||
sddl.clone(),
|
||||
"D:P(A;;FA;;;SY)\n7B".into(),
|
||||
format!("{sddl}\n7B-2"),
|
||||
format!("{sddl}\n7B--22"),
|
||||
format!("{sddl}\n7G"),
|
||||
format!("{sddl}\n+7-22"),
|
||||
] {
|
||||
assert_eq!(sddl_and_bytes(&bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes come with the descriptor of the same handle, capped at `max + 1` so an oversized
|
||||
/// file shows, and the restricted file is owned by the user.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_file_is_read_with_its_descriptor_through_one_handle() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-read-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("ctl.json");
|
||||
std::fs::write(&file, br#"{"port":1}"#).unwrap();
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
let (sddl, bytes) = read_with_sddl(&file, 64).unwrap();
|
||||
assert_eq!(section(&sddl, "O:"), Some(me.as_str()), "{sddl}");
|
||||
assert_eq!((foreign_access(&sddl, &me), bytes.as_slice()), (None, &br#"{"port":1}"#[..]));
|
||||
assert_eq!(read_with_sddl(&file, 3).unwrap().1, br#"{"po"#);
|
||||
// A limit past what the script's `[int]` holds is capped, not handed to it.
|
||||
assert_eq!(read_with_sddl(&file, u64::MAX).unwrap().1, br#"{"port":1}"#);
|
||||
assert!(read_with_sddl(&dir.join("none.json"), 64).is_err());
|
||||
// A deny entry is read as one: denying Guests takes nothing from the user.
|
||||
let guests = std::process::Command::new("icacls").arg(&file).arg("/deny").arg("*S-1-5-32-546:(R)").output().unwrap();
|
||||
assert!(guests.status.success(), "{}", String::from_utf8_lossy(&guests.stderr));
|
||||
let (sddl, _) = read_with_sddl(&file, 64).unwrap();
|
||||
assert!(sddl.contains(";;;S-1-5-32-546)") && sddl.contains("(D;"), "{sddl}");
|
||||
assert_eq!(foreign_access(&sddl, &me), None, "{sddl}");
|
||||
// The handle shares the file for reading only: a file someone has open for writing (or
|
||||
// could rename or delete) isn't read, and so can't change between the check and the read.
|
||||
let writer = std::fs::OpenOptions::new().write(true).open(&file).unwrap();
|
||||
assert!(read_with_sddl(&file, 64).is_err(), "a file open for writing is not read");
|
||||
// Restricting reads the descriptor alone, so it works on the file the app holds open to
|
||||
// write the token through.
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
assert_eq!(foreign_access(&sddl_of(&file).unwrap(), &me), None);
|
||||
drop(writer);
|
||||
assert!(read_with_sddl(&file, 64).is_ok());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Entries of the file's own, not inherited, are replaced too: an Administrators or Users
|
||||
/// entry (as the windows-latest runner's temp folder leaves on a new file, #3922) and a deny
|
||||
/// that would keep the user from setting the owner.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn entries_of_the_files_own_are_replaced() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-own-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let me = current_user_sid().unwrap();
|
||||
let mine = format!("*{me}:(R)");
|
||||
for (name, entries) in [
|
||||
("others.json", ["/grant", "*S-1-5-32-544:(F)", "/grant", "*S-1-5-32-545:(R)"]),
|
||||
("deny.json", ["/deny", "*S-1-1-0:(WO)", "/grant", "*S-1-5-32-544:(F)"]),
|
||||
] {
|
||||
let file = dir.join(name);
|
||||
std::fs::write(&file, b"{}").unwrap();
|
||||
// The user keeps read access, so the descriptor can be read before restricting.
|
||||
let own = std::process::Command::new("icacls").arg(&file).arg("/inheritance:r").args(entries).args(["/grant", &mine]).output().unwrap();
|
||||
assert!(own.status.success(), "{}", String::from_utf8_lossy(&own.stderr));
|
||||
assert!(foreign_access(&sddl_of(&file).unwrap(), &me).is_some(), "{name}");
|
||||
restrict_to_owner(&file, &user()).unwrap();
|
||||
let sddl = sddl_of(&file).unwrap();
|
||||
assert_eq!((section(&sddl, "O:"), foreign_access(&sddl, &me)), (Some(me.as_str()), None), "{name}: {sddl}");
|
||||
assert!(!sddl.contains("(D;"), "{name}: the deny is gone: {sddl}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_descriptor_still_open_to_others_after_restricting_is_an_error() {
|
||||
assert!(still_foreign(&format!("O:{ME}D:P(A;;FA;;;SY)(A;;FA;;;{ME})"), ME).is_ok());
|
||||
for bad in [format!("O:{ME}D:P(A;;FA;;;S-1-5-32-544)(A;;FA;;;{ME})"), format!("O:S-1-5-32-544D:P(A;;FA;;;{ME})"), format!("O:{ME}")] {
|
||||
assert!(still_foreign(&bad, ME).is_err_and(|e| e.to_string().contains("could not be restricted")), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`read_with_sddl`] gives accounts as full SIDs (rights as a number, no list flags), the way a
|
||||
/// CI runner's built-in Administrator reads its own restricted file. An alias such as `LA`
|
||||
/// is never taken for the user, whatever the user's RID: on a domain-joined machine a domain
|
||||
/// account can share the local Administrator's RID 500.
|
||||
#[test]
|
||||
fn descriptors_with_full_sids_and_aliases_that_are_never_the_user() {
|
||||
let admin = "S-1-5-21-1-2-3-500";
|
||||
let full = format!("O:{admin}D:(A;;2032127;;;S-1-5-18)(A;;2032127;;;{admin})");
|
||||
assert_eq!(foreign_access(&full, admin), None);
|
||||
// Owner rights by SID, a deny by SID, an inherit-only entry for Creator Owner.
|
||||
let more = format!("O:{ME}D:(D;;1179785;;;S-1-5-32-546)(A;IO;268435456;;;S-1-3-0)(A;;2032127;;;S-1-3-4)(A;;2032127;;;{ME})");
|
||||
assert_eq!(foreign_access(&more, ME), None);
|
||||
assert!(foreign_access(&format!("O:{ME}D:(A;;1179785;;;S-1-5-32-545)(A;;2032127;;;{ME})"), ME).is_some_and(|p| p.contains("S-1-5-32-545")));
|
||||
for me in [admin, "S-1-5-21-1-2-3-501"] {
|
||||
assert!(foreign_access("O:LAD:PAI(A;;FA;;;SY)(A;;FA;;;LA)", me).is_some_and(|p| p.contains("owned by another account (LA)")), "{me}");
|
||||
assert!(foreign_access("O:LGD:P(A;;FA;;;LG)", me).is_some(), "{me}");
|
||||
}
|
||||
assert!(foreign_access(&format!("O:{admin}D:P(A;;FA;;;{admin})(A;;FR;;;LA)"), admin).is_some_and(|p| p.contains("LA")));
|
||||
}
|
||||
|
||||
/// Review on #3956: a refusal names a well-known account as well as giving its SID, so
|
||||
/// `S-1-5-32-544` reads as Administrators without a lookup; any other SID is given alone.
|
||||
#[test]
|
||||
fn refusals_name_well_known_accounts_and_keep_their_sid() {
|
||||
for (sid, name) in [
|
||||
("S-1-5-32-544", "Administrators"),
|
||||
("S-1-5-32-545", "Users"),
|
||||
("S-1-5-11", "Authenticated Users"),
|
||||
("S-1-5-4", "INTERACTIVE"),
|
||||
("S-1-1-0", "Everyone"),
|
||||
("S-1-5-6", "SERVICE"),
|
||||
("S-1-5-3", "BATCH"),
|
||||
] {
|
||||
let sddl = format!("O:{ME}D:PAI(A;;FA;;;{ME})(A;ID;1179817;;;{sid})");
|
||||
assert_eq!(foreign_access(&sddl, ME), Some(format!("another account, {name} ({sid}), has access to it")), "{sddl}");
|
||||
}
|
||||
// SYSTEM may have access, but a file it owns isn't the user's.
|
||||
assert_eq!(foreign_access(&format!("O:S-1-5-18D:P(A;;FA;;;{ME})"), ME).as_deref(), Some("it is owned by another account, SYSTEM (S-1-5-18)"));
|
||||
assert_eq!(
|
||||
foreign_access(&format!("O:S-1-5-32-544D:P(A;;FA;;;{ME})"), ME).as_deref(),
|
||||
Some("it is owned by another account, Administrators (S-1-5-32-544)")
|
||||
);
|
||||
// Another user, a lookalike and an alias have no fixed name: the account is given as it was.
|
||||
for sid in ["S-1-5-21-1-2-3-1002", "S-1-5-32-5440", "S-1-5-1", "BA"] {
|
||||
let sddl = format!("O:{ME}D:(A;;FA;;;{ME})(A;;FR;;;{sid})");
|
||||
assert_eq!(foreign_access(&sddl, ME), Some(format!("another account ({sid}) has access to it")), "{sddl}");
|
||||
}
|
||||
assert_eq!(
|
||||
foreign_access("O:S-1-5-21-1-2-3-1002D:(A;;FA;;;SY)", ME).as_deref(),
|
||||
Some("it is owned by another account (S-1-5-21-1-2-3-1002)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_stop_at_the_next_tag() {
|
||||
assert_eq!(section("O:S-1-5-18G:S-1-5-32-544D:(A;;FA;;;SY)", "O:"), Some("S-1-5-18"));
|
||||
assert_eq!(section("O:BAG:SYD:P(A;;FA;;;SY)S:(AU;SA;FA;;;WD)", "D:"), Some("P(A;;FA;;;SY)"));
|
||||
assert_eq!(section("O:BA", "D:"), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user