mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-15 10:29:11 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e6d9bfa92 |
@@ -18,7 +18,4 @@ jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
with:
|
||||
# Match the main CI gate so coverage exercises every glyph-sensitive UI case.
|
||||
fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
secrets: inherit
|
||||
|
||||
Generated
+18
-19
@@ -2126,7 +2126,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -2154,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2166,7 +2166,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -2177,7 +2177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -2190,7 +2190,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
@@ -2201,7 +2201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-csv",
|
||||
"gridcraft-calc",
|
||||
@@ -2225,7 +2225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -2234,7 +2234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -2242,7 +2242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-math"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"ratex-font",
|
||||
"ratex-layout",
|
||||
@@ -2252,7 +2252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -2261,7 +2261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2274,18 +2274,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-sandbox"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"landlock",
|
||||
"libc",
|
||||
@@ -2296,7 +2296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -2323,7 +2323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -2342,14 +2342,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"craft-cfb",
|
||||
"craft-csv",
|
||||
"craft-offcrypto",
|
||||
"craft-opc",
|
||||
"craft-xls",
|
||||
"craft-xml",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
@@ -6837,7 +6836,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.1"
|
||||
version = "0.20.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -40,7 +40,6 @@ craft-opc = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0
|
||||
# turn them into workbooks.
|
||||
craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# Excel 97-2003 (.xls, BIFF8) workbooks; `crates/xlsx/src/xls.rs` maps them onto the model.
|
||||
craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
|
||||
# foundation
|
||||
|
||||
@@ -78,8 +78,6 @@ fn the_self_test_finds_every_denial() {
|
||||
] {
|
||||
assert!(out.contains(line), "missing `{line}` in:\n{out}");
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(out.contains("denied: switch a descriptor's notifications on"), "missing the notification switch in:\n{out}");
|
||||
std::fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
@@ -183,9 +181,7 @@ fn system_python() -> Option<(String, String)> {
|
||||
/// Confined, a program can't signal another process (this test, signal 0 delivering nothing), but can
|
||||
/// still signal itself and its own threads. On Linux, where the filter names them, it also can't
|
||||
/// change another process's limits or priority, or type into a terminal: no call here reaches a real
|
||||
/// terminal, since its standard input isn't one. Linux also refuses the `fcntl` commands that would
|
||||
/// have the kernel signal a process for it: naming the owner, choosing the signal, or switching the
|
||||
/// notification on.
|
||||
/// terminal, since its standard input isn't one.
|
||||
#[test]
|
||||
fn a_confined_program_leaves_other_processes_alone() {
|
||||
if !confines() {
|
||||
@@ -230,13 +226,6 @@ if sys.platform == "linux":
|
||||
assert refused(os.setpriority, os.PRIO_USER, 0, 0), "every process's priority"
|
||||
assert refused(os.sched_setaffinity, other, {0}), "another process's processors"
|
||||
assert refused(os.pidfd_open, other), "a handle on another process"
|
||||
read, write = os.pipe()
|
||||
assert refused(fcntl.fcntl, write, fcntl.F_SETOWN, other), "giving a descriptor's notifications to another process"
|
||||
# 15 is F_SETOWN_EX and 10 is F_SETSIG, which Python's fcntl doesn't name; the filter refuses
|
||||
# them whatever owner or signal they name, so the argument doesn't matter.
|
||||
assert refused(fcntl.fcntl, write, 15), "naming a descriptor's owner the extended way"
|
||||
assert refused(fcntl.fcntl, write, 10), "choosing a descriptor's notification signal"
|
||||
assert refused(fcntl.fcntl, write, fcntl.F_SETFL, fcntl.fcntl(write, fcntl.F_GETFL) | os.O_ASYNC), "switching a descriptor's notifications on"
|
||||
resource.prlimit(0, resource.RLIMIT_NOFILE)
|
||||
resource.prlimit(os.getpid(), resource.RLIMIT_NOFILE)
|
||||
os.setpriority(os.PRIO_PROCESS, 0, os.getpriority(os.PRIO_PROCESS, 0))
|
||||
@@ -251,101 +240,6 @@ print("left alone")
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("left alone"), "{}", text(&o));
|
||||
}
|
||||
|
||||
/// A socket the parent made, which the program inherited as a standard stream, can't be pointed at
|
||||
/// another process through the ioctls that do what `fcntl`'s notification commands do: `FIOSETOWN`
|
||||
/// (0x8901) and `FIOASYNC` (0x5452), which Python's `fcntl` passes straight through. Linux only:
|
||||
/// macOS's profile has no rule for them, and its standard streams here are pipes.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_confined_program_cant_point_an_inherited_socket_at_another_process() {
|
||||
if !confines() {
|
||||
return;
|
||||
}
|
||||
let Some((python, prefix)) = system_python() else {
|
||||
return;
|
||||
};
|
||||
let code = r#"
|
||||
import fcntl, sys
|
||||
other = int(sys.argv[1])
|
||||
def refused(call, *args):
|
||||
try:
|
||||
call(*args)
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError as e:
|
||||
print("other error", e)
|
||||
return False
|
||||
assert refused(fcntl.ioctl, 0, 0x8901, other), "giving a socket's notifications to another process"
|
||||
assert refused(fcntl.ioctl, 0, 0x5452, 1), "switching a socket's notifications on"
|
||||
print("socket quiet")
|
||||
"#;
|
||||
let (mine, _) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||
let socket: std::os::fd::OwnedFd = mine.into();
|
||||
let me = std::process::id().to_string();
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
|
||||
.args(["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me])
|
||||
.stdin(std::process::Stdio::from(socket))
|
||||
.output()
|
||||
.unwrap();
|
||||
let out = text(&o);
|
||||
assert!(o.status.success(), "{out}");
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("socket quiet"), "{out}");
|
||||
}
|
||||
|
||||
/// The same inherited socket, by both ioctls that name the process to notify, `FIOSETOWN` (0x8901)
|
||||
/// and `SIOCSPGRP` (0x8902), with the argument a real pointer. Named by value the kernel reads the
|
||||
/// number as an address and fails with `EFAULT` whether or not the filter refuses it, so that form
|
||||
/// shows only that a rule matched a request number; by pointer each call succeeds unconfined and is
|
||||
/// `EPERM` confined, which is the transition the page claims. Kept apart from the test above so a
|
||||
/// rule dropped for one request number fails for that request, not behind another. Linux only, as
|
||||
/// those rules are: macOS's profile has none, and its standard streams here are pipes.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_confined_program_cant_point_an_inherited_socket_at_another_process_by_pointer() {
|
||||
if !confines() {
|
||||
return;
|
||||
}
|
||||
let Some((python, prefix)) = system_python() else {
|
||||
return;
|
||||
};
|
||||
let code = r#"
|
||||
import fcntl, socket, struct, sys
|
||||
other = int(sys.argv[1])
|
||||
def refused(call, *args):
|
||||
try:
|
||||
call(*args)
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError as e:
|
||||
print("other error", e)
|
||||
return False
|
||||
# Read first, so a standard stream that is not a socket can't pass for one: a pipe has no socket name.
|
||||
# Detached, not dropped: a socket object closes the descriptor it wrapped when it is collected, and then
|
||||
# the refusals below would be read off an invalid descriptor.
|
||||
wrapped = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, fileno=0)
|
||||
name = wrapped.getsockname()
|
||||
wrapped.detach()
|
||||
assert name == ""
|
||||
# The process group is a pointer, as a C caller passes it; naming it by value has the kernel read the
|
||||
# number as an address and fail with EFAULT, which would leave the refusal unexplained by the filter.
|
||||
group = bytearray(struct.pack("i", other))
|
||||
assert refused(fcntl.ioctl, 0, 0x8901, group), "giving a socket's notifications to another process"
|
||||
assert refused(fcntl.ioctl, 0, 0x8902, group), "giving a socket's notifications to another process's group"
|
||||
print("socket owner quiet")
|
||||
"#;
|
||||
let (mine, _) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||
let socket: std::os::fd::OwnedFd = mine.into();
|
||||
let me = std::process::id().to_string();
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
|
||||
.args(["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me])
|
||||
.stdin(std::process::Stdio::from(socket))
|
||||
.output()
|
||||
.unwrap();
|
||||
let out = text(&o);
|
||||
assert!(o.status.success(), "{out}");
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("socket owner quiet"), "{out}");
|
||||
}
|
||||
|
||||
/// Confined, a program changes no file, whether the policy lets it read that file or not, by path or
|
||||
/// by a descriptor: not its contents, permissions, owner, extended attributes, times or flags.
|
||||
/// Skipped when there is no `python3`.
|
||||
|
||||
@@ -268,38 +268,12 @@ const TIOCLINUX: u64 = libc::TIOCLINUX as u64;
|
||||
const IOC_WRITE: u64 = 0x4000_0000;
|
||||
/// Changes a directory, though its number says it only reads, as the kernel's headers have it.
|
||||
const FS_IOC_SET_ENCRYPTION_POLICY: u64 = 0x800C_6613;
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
const F_SETOWN: u64 = libc::F_SETOWN as u64;
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
const F_SETFL: u64 = libc::F_SETFL as u64;
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
const O_ASYNC: u64 = libc::O_ASYNC as u64;
|
||||
/// The two `fcntl` commands of the same family that the kernel's headers name and `libc` doesn't;
|
||||
/// the numbers are the same on every architecture (`asm-generic/fcntl.h`).
|
||||
const F_SETOWN_EX: u64 = 15;
|
||||
const F_SETSIG: u64 = 10;
|
||||
/// Socket ioctls that do what those commands do: `FIOSETOWN` and `SIOCSPGRP` name the process to
|
||||
/// notify, and `FIOASYNC` switches the notification on. Their requests carry no data-direction
|
||||
/// bit, so the [`IOC_WRITE`] rule doesn't name them. `libc` doesn't name them; the numbers are
|
||||
/// the kernel's, the same on every architecture (`asm-generic/sockios.h`).
|
||||
const FIOSETOWN: u64 = 0x8901;
|
||||
const SIOCSPGRP: u64 = 0x8902;
|
||||
const FIOASYNC: u64 = 0x5452;
|
||||
|
||||
/// What the main filter refuses with `EPERM`: the calls above, and these when their arguments match.
|
||||
/// - `clone` without `CLONE_THREAD`: a new process.
|
||||
/// - A signal to any process but `me`, whose ID `exec` keeps. Landlock's signal scope needs a newer
|
||||
/// kernel than the rest (ABI 6), and `kill(-1, 9)` must not get through on an older one.
|
||||
/// - A change to the limits, priority or scheduling of any process but `me`.
|
||||
/// - The `fcntl` commands that arrange asynchronous notification: naming the process to notify
|
||||
/// (`F_SETOWN`, `F_SETOWN_EX`), choosing its signal (`F_SETSIG`), and `F_SETFL` when it sets
|
||||
/// `O_ASYNC`, the switch without which nothing is sent. None is a signal call, and the only rule
|
||||
/// that would cover them is Landlock's signal scope, which needs ABI 6, newer than a launch
|
||||
/// requires; without these a confined program could have the kernel signal any process of its
|
||||
/// user. `F_SETFL` itself still works, as `O_NONBLOCK` comes from it. A socket answers the same
|
||||
/// arrangement through ioctls — `FIOSETOWN` and `SIOCSPGRP` to name the process, `FIOASYNC` to
|
||||
/// switch the notification on — and those requests carry no data-direction bit, so they are
|
||||
/// named alongside the others.
|
||||
/// - `TIOCSTI` and `TIOCLINUX`, which type into a terminal the program inherited, as when
|
||||
/// `gridcraft-cli sandbox-check` runs a program from a shell.
|
||||
/// - An `ioctl` that hands the kernel data ([`IOC_WRITE`]), as those that change a file's flags or
|
||||
@@ -333,22 +307,10 @@ fn main_rules(me: u32) -> Result<BTreeMap<i64, Vec<SeccompRule>>> {
|
||||
vec![
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, TIOCSTI)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, TIOCLINUX)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, FIOSETOWN)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, SIOCSPGRP)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, FIOASYNC)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::MaskedEq(IOC_WRITE), IOC_WRITE)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, FS_IOC_SET_ENCRYPTION_POLICY)?])?,
|
||||
],
|
||||
);
|
||||
rules.insert(
|
||||
libc::SYS_fcntl,
|
||||
vec![
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, F_SETOWN)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, F_SETOWN_EX)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, F_SETSIG)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, F_SETFL)?, word(2, SeccompCmpOp::MaskedEq(O_ASYNC), O_ASYNC)?])?,
|
||||
],
|
||||
);
|
||||
Ok(rules)
|
||||
}
|
||||
|
||||
@@ -416,9 +378,6 @@ mod tests {
|
||||
#[test]
|
||||
fn the_calls_that_reach_another_process_have_rules() {
|
||||
let rules = main_rules(4242).unwrap();
|
||||
// `fcntl`'s notification commands: naming the owner to notify, both ways, choosing its
|
||||
// signal, and the switch itself.
|
||||
assert_eq!(rules.get(&libc::SYS_fcntl).map(Vec::len), Some(4), "the asynchronous-notification commands");
|
||||
for nr in [
|
||||
libc::SYS_kill,
|
||||
libc::SYS_tkill,
|
||||
@@ -456,9 +415,7 @@ mod tests {
|
||||
] {
|
||||
assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{nr} is refused outright");
|
||||
}
|
||||
// The terminal rules, the socket notification ioctls (`FIOSETOWN`, `SIOCSPGRP`,
|
||||
// `FIOASYNC`), the data-carrying rule and the encryption rule.
|
||||
assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(7));
|
||||
assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -44,8 +44,6 @@ pub fn enforcement(canary: &Path, exe: &Path, target: u32) -> Vec<Check> {
|
||||
Check { name: "TCP connection", outcome: denied(tcp_probe()) },
|
||||
Check { name: "start a process", outcome: spawn_probe(exe) },
|
||||
Check { name: "signal another process", outcome: signal_probe(target) },
|
||||
#[cfg(target_os = "linux")]
|
||||
Check { name: "switch a descriptor's notifications on", outcome: async_probe() },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -130,20 +128,6 @@ fn write_probe(dir: &Path) -> Outcome {
|
||||
denied(r.map(drop))
|
||||
}
|
||||
|
||||
/// `O_ASYNC` on a descriptor of the program's own: the switch that has the kernel signal the
|
||||
/// descriptor's owner when its state changes. The owner may be a process the program didn't name, a
|
||||
/// standard stream the parent made, so on Linux the filter refuses setting it, the same way it
|
||||
/// refuses the commands that name another owner. Naming one can't be asked here without `fcntl`'s
|
||||
/// variable-argument form, which no safe API wraps; the rules refuse those commands, and the
|
||||
/// confined-program test covers them.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn async_probe() -> Outcome {
|
||||
let r = nix::unistd::pipe().map_err(std::io::Error::from).and_then(|(_, write)| {
|
||||
nix::fcntl::fcntl(write, nix::fcntl::FcntlArg::F_SETFL(nix::fcntl::OFlag::O_ASYNC)).map(drop).map_err(std::io::Error::from)
|
||||
});
|
||||
denied(r)
|
||||
}
|
||||
|
||||
fn tcp_probe() -> std::io::Result<()> {
|
||||
TcpStream::connect_timeout(&SocketAddr::from(TCP_TARGET), Duration::from_secs(2)).map(drop)
|
||||
}
|
||||
@@ -232,15 +216,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unconfined, the notification probe reports what the system allows; confined, the launch
|
||||
/// check needs it `Denied`. Either way it comes out of the same three calls, so this proves
|
||||
/// the probe runs and classifies rather than failing outright.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn the_notification_probe_classifies() {
|
||||
assert!(matches!(async_probe(), Outcome::Allowed(_)), "unconfined the switch can be set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_checks_on_the_canary_change_nothing_where_they_are_allowed() {
|
||||
let canary = crate::Canary::create().unwrap();
|
||||
|
||||
@@ -23,7 +23,6 @@ craft-csv = { workspace = true }
|
||||
craft-offcrypto = { workspace = true }
|
||||
craft-opc = { workspace = true }
|
||||
craft-xls = { workspace = true }
|
||||
craft-xml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
+48
-19
@@ -359,9 +359,31 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
|
||||
|
||||
// ---------------------------------------------------------------- writing helpers
|
||||
|
||||
// XML 1.0 byte escaping is shared; spreadsheet string and phonetic policy stays here.
|
||||
use craft_xml::is_xml_char;
|
||||
pub use craft_xml::{esc, esc_attr, push_esc};
|
||||
/// Escapes text content.
|
||||
pub fn esc(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
push_esc(&mut o, s);
|
||||
o
|
||||
}
|
||||
|
||||
/// Appends [`esc`]`(s)` to `o`.
|
||||
pub fn push_esc(o: &mut String, s: &str) {
|
||||
if s.bytes().all(|b| matches!(b, b'\t' | b'\n' | 0x20..=0x7E) && !matches!(b, b'&' | b'<' | b'>')) {
|
||||
o.push_str(s);
|
||||
return;
|
||||
}
|
||||
o.reserve(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => o.push_str("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'\r' => o.push_str(" "),
|
||||
c if is_xml_char(c) => o.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends `n` in decimal.
|
||||
pub fn push_u64(o: &mut String, mut n: u64) {
|
||||
@@ -400,6 +422,29 @@ pub fn push_a1(o: &mut String, c: gridcraft_core::CellRef) {
|
||||
push_u64(o, u64::from(c.row) + 1);
|
||||
}
|
||||
|
||||
/// Escapes an attribute value (double-quoted).
|
||||
pub fn esc_attr(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => o.push_str("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
'\n' => o.push_str(" "),
|
||||
'\r' => o.push_str(" "),
|
||||
'\t' => o.push_str("	"),
|
||||
c if is_xml_char(c) => o.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn is_xml_char(c: char) -> bool {
|
||||
matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
|
||||
}
|
||||
|
||||
/// ST_Xstring encoding for cell text: characters XML can't carry and carriage returns become
|
||||
/// `_xHHHH_`, and literal `_xHHHH_` sequences are protected with `_x005F_`. The result still needs
|
||||
/// [`esc`].
|
||||
@@ -760,19 +805,3 @@ mod tests {
|
||||
assert_eq!("1.5E20".parse::<f64>().unwrap(), 1.5e20);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_escape_parity {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn xml_content_and_attributes_preserve_grid_bytes() {
|
||||
let value = "&<>\"'\t\n\r\0\u{1f}\u{fffe}\u{ffff}é😀";
|
||||
assert_eq!(esc(value), "&<>\"'\t\n é😀");
|
||||
assert_eq!(esc_attr(value), "&<>"'	 é😀");
|
||||
let mut appended = "prefix:".to_owned();
|
||||
push_esc(&mut appended, value);
|
||||
assert_eq!(appended, format!("prefix:{}", esc(value)));
|
||||
assert_eq!(esc("plain ASCII\t\n"), "plain ASCII\t\n");
|
||||
assert_eq!(decode_xstring(&encode_xstring("_x000D_\r\0😀")), "_x000D_\r\0😀");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# The sandbox for helper processes
|
||||
|
||||
> **Last updated:** 2026-11-12 · **Change:** minor (no signals to another process on Linux, not even indirectly: `fcntl`'s notification commands and the socket ioctls that do the same are refused; earlier: no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`)
|
||||
> **Last updated:** 2026-11-07 · **Change:** minor (no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`)
|
||||
|
||||
The sandbox is the layer GridCraft is to run code it doesn't trust in, such as a workbook's
|
||||
Python cells: a separate process that confines itself before the code starts. Here only
|
||||
@@ -24,7 +24,7 @@ other programs. The sandbox's job is that it can do none of these:
|
||||
- **Network:** no sockets, no connections and no listeners. The exception is a standard stream the
|
||||
parent made a socket (see below).
|
||||
- **Other processes and the system:** no debugging or reading another process, no System V shared
|
||||
memory, message queues or semaphores, no POSIX message queues, no signals to another process — on Linux not even indirectly, as the `fcntl` commands and socket ioctls that have the kernel send one are refused — no typing into a terminal it inherited, no mounts or namespaces, no kernel
|
||||
memory, message queues or semaphores, no POSIX message queues, no signals to another process, no typing into a terminal it inherited, no mounts or namespaces, no kernel
|
||||
modules, BPF, keyrings or performance counters. On Linux it also can't change another process's
|
||||
limits, priority or scheduling.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user