Compare commits

...
Author SHA1 Message Date
FredApps a204c63c40 CI: use the shared git-sources check
nix.yml ran its own copy of the git-sources job; it now calls
craft-common-actions git-sources.yml. release.yml's copy is removed:
the shared release.yml runs the same check before any build.
2026-11-11 23:01:27 +01:00
Dylan M. Taylor 5a6aa9b65f fix: bound or confine seven name, path and file inputs (#2732)
* fix: bound or confine seven name, path and file inputs (QSELECT wildcard, layout template name, LISP OPEN/CLOSE, font manifest, xref palette, test walkers)

* address review: reserved device names, fresh fifo test dir, stat before open in build.rs

* review: honour the name alias and Unicode case in exact xrefs, reserve Windows device basenames, charge line headers, skip non-directories in the module walk

* address review: distinct exact xref names, exact-capacity LISP line queue, fail the module walk on entry errors

* address review: xrefs differing only by case cannot be loaded together; panic-free default template name

* address review: refuse detach and path changes across case-variant xrefs; ASCII case folding for xref names

* address review: report the case-variant xref a load skips; assert both xref paths in the test
2026-11-11 16:11:19 -05:00
Dylan M. Taylor b460a3b8d9 fix(engine): an atomic replacement keeps the old file's group, or drops the group bits (#2743)
* fix(engine): an atomic replacement keeps the old file's group, or drops the group bits

* address review: backup group bits, skip no-op fchown

* fix(engine): keep atomic writes of one path serialized, backup bits capped by the drawing's group

* fix(engine): the first backup takes the drawing's group, else drops its group bits
2026-11-11 16:10:52 -05:00
Dylan M. Taylor cb96c182ef fix(fonts): one retained-point allowance per MTEXT layout and per stroke line (#3070)
* fix(fonts): one retained-point allowance per MTEXT layout and per stroke line

* fix(fonts): the layout allowance counts both retained copies
2026-11-11 16:10:25 -05:00
Dylan M. Taylor e1d379ad9f chore: bump version to 0.20.1 for the Flatpak update fix (#3084) 2026-11-11 14:55:43 -05:00
Dylan M. Taylor dc7f382cec Fix Flatpak permissions for update checks and self-updates (#3075)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:24:01 -05:00
FredApps e5021f0175 Merge pull request #3047 from V1dalTheAbidal/pr/bsaveas-source
Block Editor: BSAVEAS keeps the source block's last saved definition
2026-11-11 18:56:45 +01:00
Dylan M. Taylor 2d25308cfc Merge pull request #3068 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:43:38 -05:00
Dylan M. Taylor 20b4472f0b Updates: take craft-update from craft-libs main at 6e03f65bf 2026-11-11 12:43:30 -05:00
Dylan M. Taylor ecb362aa55 ci: run Harden-Runner on every job (#3058)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:09:19 -05:00
FredApps dea9f2d048 Release: CADCraft v0.20.0 (#3054) 2026-11-11 16:38:50 +01:00
V1dalTheAbidal f132d81609 Document the BSAVEAS source test 2026-11-11 11:14:22 +02:00
V1dalTheAbidal 650ae383f2 Block Editor: BSAVEAS keeps the source block's last saved definition
After BSAVE the editor's block table can still hold the older source definition.
Restore the saved source block from the host when composing a copy and when
reconciling the editor and its history, so a later save of the copy keeps it.
2026-11-11 01:32:16 +02:00
26 changed files with 618 additions and 148 deletions
+2 -36
View File
@@ -23,39 +23,5 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/git-sources.yml@main
+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+1 -39
View File
@@ -24,47 +24,9 @@ jobs:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: [git-sources, credits]
needs: credits
permissions:
# The reusable release workflow tags and uploads the draft release.
contents: write
Generated
+18 -17
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -645,7 +645,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -661,14 +661,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -678,7 +678,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -689,7 +689,7 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"acadrust",
"log",
@@ -697,7 +697,7 @@ dependencies = [
[[package]]
name = "cadcraft-dxf"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"encoding_rs",
"log",
@@ -706,7 +706,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -727,7 +727,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
@@ -740,14 +740,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -769,7 +769,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -784,7 +784,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -798,7 +798,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -825,7 +825,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
@@ -1214,7 +1214,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1224,6 +1224,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -5993,7 +5994,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"chrono",
"serde_json",
+2 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.17.0"
version = "0.20.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -71,7 +71,7 @@ egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
+85 -1
View File
@@ -16,10 +16,31 @@ use std::fs::{File, OpenOptions, Permissions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
static SEQ: AtomicU64 = AtomicU64::new(0);
/// Locks held from destination metadata inspection through rename, so concurrent writes cannot
/// combine permissions from one version of a path with the group of another.
fn path_lock(path: &Path) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<std::collections::HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir().unwrap_or_default().join(path) };
let key = match (absolute.parent(), absolute.file_name()) {
(Some(parent), Some(name)) => std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()).join(name),
_ => absolute,
};
let mut locks = LOCKS.get_or_init(|| Mutex::new(std::collections::HashMap::new())).lock().unwrap_or_else(std::sync::PoisonError::into_inner);
locks.retain(|_, lock| lock.strong_count() > 0);
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
lock
} else {
let lock = Arc::new(Mutex::new(()));
locks.insert(key, Arc::downgrade(&lock));
lock
}
}
/// How many fresh temporary names to try before giving up (each collision is another local race).
const MAX_TRIES: u32 = 32;
@@ -33,13 +54,15 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
// A symlinked destination is resolved so the link stays a link and its target is replaced.
let resolved = resolve_symlink(path)?;
let path = resolved.as_deref().unwrap_or(path);
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let permissions = match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
Ok(_) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
write_atomic_with(path, permissions, |file| file.write_all(bytes))
write_atomic_with_locked(path, permissions, |file| file.write_all(bytes))
}
/// The topmost folder missing above `path`: what [`write_atomic_making_folders`] will create.
@@ -67,10 +90,30 @@ pub fn write_atomic_making_folders(path: &Path, bytes: &[u8]) -> io::Result<()>
/// with `permissions` given to it from its creation (`None`: the usual creation mode). A symlink
/// at `path` is replaced by the new file, never written through.
pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
write_atomic_with_locked(path, permissions, fill)
}
/// [`write_atomic_with`] with permissions derived under the same destination lock as the write.
pub fn write_atomic_with_permissions(
path: &Path,
permissions: impl FnOnce() -> io::Result<Option<Permissions>>,
fill: impl FnOnce(&mut File) -> io::Result<()>,
) -> io::Result<()> {
let lock = path_lock(path);
let _guard = lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let permissions = permissions()?;
write_atomic_with_locked(path, permissions, fill)
}
fn write_atomic_with_locked(path: &Path, permissions: Option<Permissions>, fill: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
let write = (|| {
if let Some(permissions) = permissions {
#[cfg(unix)]
let permissions = keep_group(&file, path, permissions);
file.set_permissions(permissions)?;
}
fill(&mut file)?;
@@ -90,6 +133,20 @@ pub fn write_atomic_with(path: &Path, permissions: Option<Permissions>, fill: im
Ok(())
}
/// `permissions` for the new file when `path` already exists: the group bits mean "this group" only
/// if the file is still in the old file's group, so the group is restored first. When it can't be
/// (the user is not a member of it), the group bits are dropped rather than handed to another group.
#[cfg(unix)]
fn keep_group(file: &File, path: &Path, permissions: Permissions) -> Permissions {
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
let Ok(old) = std::fs::symlink_metadata(path) else { return permissions };
// A file already in the old group needs no change (and a restrictive syscall policy may refuse a no-op fchown).
if !old.is_file() || file.metadata().is_ok_and(|m| m.gid() == old.gid()) || fchown(file, None, Some(old.gid())).is_ok() {
return permissions;
}
Permissions::from_mode(permissions.mode() & !0o070)
}
/// Flush `dir` so a rename just made in it survives a crash or power loss (POSIX records a
/// rename in the directory, not in the file). Best effort: some file systems (network shares)
/// refuse to sync a directory, and the file itself is already on disk.
@@ -231,6 +288,33 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// The replacement keeps the old file's group (when the user is in another group, as `id -G`
/// says) together with its mode, so the group bits never name a different group.
#[cfg(unix)]
#[test]
fn keeps_the_group_and_mode_of_the_file_it_replaces() {
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
let dir = std::env::temp_dir().join(format!("cadcraft-aw-grp-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir).unwrap();
let dest = dir.join("out.bin");
std::fs::write(&dest, b"old").unwrap();
std::fs::set_permissions(&dest, Permissions::from_mode(0o640)).unwrap();
let own = std::fs::metadata(&dest).unwrap().gid();
let other = std::process::Command::new("id")
.arg("-G")
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
if let Some(gid) = other {
chown(&dest, None, Some(gid)).unwrap();
}
let want = std::fs::metadata(&dest).unwrap();
write_atomic(&dest, b"new").unwrap();
let got = std::fs::metadata(&dest).unwrap();
assert_eq!((got.gid(), got.mode() & 0o777), (want.gid(), 0o640));
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn does_not_follow_a_planted_temp_symlink() {
+9 -12
View File
@@ -144,10 +144,10 @@ impl BlockEdit {
}
let host = &self.host.doc;
let mut d = editor.clone();
// When the block is saved under another name, the edited block keeps the drawing's
// description and block `name` gets the description changed in the editor.
// When saving a copy, the source keeps its last saved definition. The editor's own
// table entry can still be the one from before BSAVE; the copy gets the edited data.
if !name.eq_ignore_ascii_case(&self.name) {
keep_description(&mut d, host, &self.name);
restore_saved_definition(&mut d, host, &self.name);
}
d.model = host.model.clone();
d.layouts = host.layouts.clone();
@@ -176,16 +176,13 @@ impl BlockEdit {
}
}
/// Give block `name` of `d` the description `host` has for it, or, when `host` has no such block,
/// drop the empty definition a description gives a block the editor started and never saved.
fn keep_description(d: &mut Drawing, host: &Drawing, name: &str) {
/// Keep the source block's saved objects and metadata when the edited objects are saved as a copy.
/// Without a saved source, drop the empty definition an editor description may have created.
fn restore_saved_definition(d: &mut Drawing, host: &Drawing, name: &str) {
match host.block(name) {
Some(h) => {
if let Some((_, b)) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(name))
&& b.description != h.description
{
Arc::make_mut(b).description = h.description.clone();
}
let key = d.blocks.keys().find(|key| key.eq_ignore_ascii_case(name)).cloned().unwrap_or_else(|| h.name.clone());
d.blocks.insert(key, h.clone());
}
None => d.blocks.retain(|k, b| !(k.eq_ignore_ascii_case(name) && b.entities.is_empty())),
}
@@ -302,7 +299,7 @@ impl DocState {
let was = be.name.clone();
let reconcile = |doc: &mut Arc<Drawing>| {
let editor = Arc::make_mut(doc);
keep_description(editor, &d, &was);
restore_saved_definition(editor, &d, &was);
editor.blocks.retain(|k, _| !k.eq_ignore_ascii_case(&name));
editor.blocks.insert(name.clone(), b.clone());
};
+56 -1
View File
@@ -34,7 +34,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
.noundo(),
CommandSpec::new("-xref", "External References", run_dash_xref)
.alias(&["-xr"])
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
.params("{option: \"?\"|bind|detach|path|unload|reload|overlay|attach|type, names?: \"A,B*\" (default *), exact?: bool (names are literal xref names, no wildcards or commas), path? (path, attach, overlay), insert?: bool (bind: Insert type), overlay?: bool (type), at?, scale?, rotation? (attach)}")
.interactive(|_| Ok(Box::new(XrefM::new("-XREF", XStage::Option)))),
CommandSpec::new("xbind", "Bind...", run_xbind)
.menu(&["Modify", "Object", "External Reference", "Bind..."])
@@ -201,6 +201,46 @@ fn run_xattach(s: &mut Session, p: &Value) -> Result<Value> {
/// The xrefs a JSON call names (`names`, default all); an error when none match.
fn named(s: &Session, cmd: &str, p: &Value) -> Result<Vec<String>> {
if p.get("exact").and_then(Value::as_bool) == Some(true) {
let lits: Vec<&str> = match p.get("names") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect(),
Some(Value::String(t)) => vec![t.as_str()],
_ => str_param(p, "name").into_iter().collect(),
};
let d = s.doc()?;
let mut names = Vec::new();
let mut selected = std::collections::HashSet::new();
// Symbol names are case-insensitive: two xrefs that differ only by case can't be loaded together.
let mut folded = std::collections::HashMap::new();
for literal in &lits {
if let Some(block) = xrefs(d).find(|block| block.name == *literal) {
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
}
if selected.insert(block.name.clone()) {
names.push(block.name.clone());
}
continue;
}
let normalized = literal.to_ascii_lowercase();
let mut matches = xrefs(d).filter(|block| block.name.to_ascii_lowercase() == normalized);
if let Some(block) = matches.next() {
if matches.next().is_some() {
return Err(bad(cmd, format!("xref name \"{literal}\" is ambiguous ignoring case")));
}
if let Some(first) = folded.insert(block.name.to_ascii_lowercase(), block.name.clone()).filter(|f| *f != block.name) {
return Err(bad(cmd, format!("xref names \"{first}\" and \"{}\" differ only by case", block.name)));
}
if selected.insert(block.name.clone()) {
names.push(block.name.clone());
}
}
}
if names.is_empty() {
return Err(bad(cmd, format!("no xref named \"{}\"", lits.join(","))));
}
return Ok(names);
}
let pattern = match p.get("names") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(","),
Some(Value::String(t)) => t.clone(),
@@ -223,6 +263,19 @@ fn run_xref(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(r)
}
/// An error when two of `names` (or, with `all`, one of them and any other attached xref) differ only
/// by case: symbol names are case-insensitive, so such xrefs cannot be handled together, and the
/// command must refuse before it changes anything.
fn refuse_case_clash(d: &Drawing, cmd: &str, names: &[String], all: bool) -> Result<()> {
let others: Vec<&str> = if all { xrefs(d).map(|b| b.name.as_str()).collect() } else { names.iter().map(String::as_str).collect() };
for n in names {
if let Some(o) = others.iter().find(|o| *o != n && o.eq_ignore_ascii_case(n)) {
return Err(bad(cmd, format!("xref names \"{n}\" and \"{o}\" differ only by case")));
}
}
Ok(())
}
fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
let option = str_param(p, "option").unwrap_or("?").trim().to_ascii_lowercase();
let host = host(s);
@@ -252,6 +305,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
}
"detach" | "d" => {
let names = named(s, "-xref", p)?;
refuse_case_clash(s.doc()?, "-xref", &names, true)?;
let mut erased = 0;
for n in &names {
erased += load::detach(s.doc_mut()?, n);
@@ -276,6 +330,7 @@ fn run_dash_xref(s: &mut Session, p: &Value) -> Result<Value> {
"path" | "p" => {
let path = str_param(p, "path").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("-xref", "`path` is required"))?.to_string();
let names = named(s, "-xref", p)?;
refuse_case_clash(s.doc()?, "-xref", &names, false)?;
let d = s.doc_mut()?;
for n in &names {
if let Some(b) = d.blocks.get_mut(n) {
+17 -1
View File
@@ -313,7 +313,23 @@ pub(crate) fn load(d: &mut Drawing, name: &str, host: Option<&str>, chosen: bool
/// [`load`] the xrefs `names` within one set of limits.
pub(crate) fn load_each(d: &mut Drawing, names: &[String], host: Option<&str>, chosen: bool) -> Vec<Outcome> {
let mut cx = Ctx::new(host);
names.iter().flat_map(|n| load_with(d, n, host, chosen, &mut cx)).collect()
// Names are case-insensitive, so a name that differs only by case from an earlier one is the same xref.
let mut seen = std::collections::HashMap::new();
names
.iter()
.flat_map(|n| match seen.entry(n.to_ascii_lowercase()) {
std::collections::hash_map::Entry::Vacant(v) => {
v.insert(n.as_str());
load_with(d, n, host, chosen, &mut cx)
}
// Reported, not silent: the skipped xref keeps its status and is not loaded here.
std::collections::hash_map::Entry::Occupied(o) => vec![Outcome {
name: String::new(),
status: XrefStatus::Unresolved,
detail: format!("xref \"{n}\" not loaded: it differs only by case from \"{}\"", o.get()),
}],
})
.collect()
}
impl Ctx {
+95 -11
View File
@@ -625,6 +625,40 @@ pub fn save_keeps_current(_path: &std::path::Path) -> bool {
false
}
/// The mode of a new backup: no more open than the drawing (`src_mode`, group `src_gid`) nor than the
/// `.bak` it replaces (`old`: mode and group). The backup keeps the old `.bak`'s group, so when that
/// differs from the drawing's group, its group bits are dropped and its other bits are capped by
/// the drawing's group bits: a member of the drawing's group must not gain access through `other`.
#[cfg(unix)]
fn backup_mode(src_mode: u32, src_gid: u32, old: Option<(u32, u32)>) -> u32 {
let mut mode = src_mode & 0o777;
if let Some((old_mode, old_gid)) = old {
mode &= old_mode;
if old_gid != src_gid {
mode = without_group(src_mode, mode);
}
}
mode
}
/// `mode` for a backup that is not in the drawing's group: no group bits, and `other` capped by the
/// drawing's group bits (`src_mode`).
#[cfg(unix)]
fn without_group(src_mode: u32, mode: u32) -> u32 {
(mode & !0o070) & (!0o007 | ((src_mode & 0o070) >> 3))
}
/// A first backup is created in the process's (or folder's) group, not the drawing's: move it to the
/// drawing's group before any data is written, else drop its group bits ([`without_group`]).
#[cfg(unix)]
fn adopt_source_group(out: &std::fs::File, src: &std::fs::Metadata) -> std::io::Result<()> {
use std::os::unix::fs::{MetadataExt, PermissionsExt, fchown};
if out.metadata()?.gid() == src.gid() || fchown(out, None, Some(src.gid())).is_ok() {
return Ok(());
}
out.set_permissions(std::fs::Permissions::from_mode(without_group(src.mode(), src.mode() & 0o777)))
}
/// Before `path` is replaced: copy it to its `.bak`, streamed and written atomically too
/// ([`crate::atomic_write::write_atomic_with`]). Nothing
/// to keep when there is no file yet. A file that doesn't look like a drawing
@@ -648,19 +682,23 @@ fn backup(path: &std::path::Path) -> std::io::Result<()> {
// failed write leaves the old .bak as it was; a symlink at the .bak is replaced, never
// written through.
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt;
let mut mode = src.metadata()?.permissions().mode() & 0o777;
if let Ok(m) = std::fs::symlink_metadata(&bak)
&& m.is_file()
{
mode &= m.permissions().mode();
}
Some(std::fs::Permissions::from_mode(mode))
let source_metadata = src.metadata()?;
#[cfg(unix)]
let first_backup = std::cell::Cell::new(false);
#[cfg(unix)]
let permissions = || {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let old = std::fs::symlink_metadata(&bak).ok().filter(|m| m.is_file());
first_backup.set(old.is_none());
Ok(Some(std::fs::Permissions::from_mode(backup_mode(source_metadata.mode(), source_metadata.gid(), old.map(|m| (m.mode(), m.gid()))))))
};
#[cfg(not(unix))]
let permissions = None;
crate::atomic_write::write_atomic_with(&bak, permissions, |out| {
let permissions = || Ok(None);
crate::atomic_write::write_atomic_with_permissions(&bak, permissions, |out| {
#[cfg(unix)]
if first_backup.get() {
adopt_source_group(out, &source_metadata)?;
}
std::io::Write::write_all(out, &head)?;
std::io::copy(&mut src, out).map(|_| ())
})
@@ -1031,6 +1069,52 @@ mod tests {
use crate::Session;
use crate::temp_dir::TempDir;
/// A backup is never more open than the drawing or the old `.bak`, and loses the group bits when
/// it would keep the old `.bak`'s group while the drawing is in another.
#[cfg(unix)]
#[test]
fn backup_mode_drops_group_bits_for_a_different_group() {
use super::backup_mode;
assert_eq!(backup_mode(0o640, 10, None), 0o640);
assert_eq!(backup_mode(0o640, 10, Some((0o664, 10))), 0o640);
assert_eq!(backup_mode(0o640, 10, Some((0o664, 20))), 0o600);
assert_eq!(backup_mode(0o644, 10, Some((0o644, 20))), 0o604);
assert_eq!(backup_mode(0o600, 10, Some((0o664, 10))), 0o600);
}
/// With no `.bak` yet, the first backup takes the drawing's group (when the user is in another
/// group, as `id -G` says), never the group the new file would be created in.
#[cfg(unix)]
#[test]
fn first_backup_takes_the_drawings_group() {
use std::os::unix::fs::{MetadataExt, PermissionsExt, chown};
let dir = TempDir::new("first-bak-grp");
let src = dir.join("a.dxf");
std::fs::write(&src, "0\nSECTION\n2\nHEADER\n0\nENDSEC\n0\nEOF\n").unwrap();
std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o640)).unwrap();
let own = std::fs::metadata(&src).unwrap().gid();
let other = std::process::Command::new("id")
.arg("-G")
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().filter_map(|g| g.parse::<u32>().ok()).find(|g| *g != own));
if let Some(gid) = other {
chown(&src, None, Some(gid)).unwrap();
}
super::backup(&src).unwrap();
let (s, b) = (std::fs::metadata(&src).unwrap(), std::fs::metadata(super::backup_path(&src)).unwrap());
assert_eq!((b.gid(), b.mode() & 0o777), (s.gid(), 0o640));
}
/// The fail-safe when the group cannot be adopted: no group bits, `other` capped by the group's.
#[cfg(unix)]
#[test]
fn without_group_drops_group_bits_and_caps_other() {
assert_eq!(super::without_group(0o640, 0o640), 0o600);
assert_eq!(super::without_group(0o604, 0o604), 0o600);
assert_eq!(super::without_group(0o644, 0o644), 0o604);
}
fn active_title(s: &Session) -> String {
s.state().unwrap().title.clone()
}
+52 -4
View File
@@ -26,11 +26,30 @@ const MAX_PURGE_PASSES: usize = 64;
/// The file a SAveas typed at the command line suggests: `<layout>.dwt` beside the drawing.
pub(super) fn default_path(s: &Session, layout: &str) -> String {
let file = format!("{layout}.dwt");
match s.state().ok().and_then(|st| st.path.clone()) {
Some(p) => std::path::Path::new(&p).with_file_name(&file).to_string_lossy().into_owned(),
None => file,
// A layout name from a file may hold path separators or `..`; the suggestion is one file name
// beside the drawing, so those become `_`.
let name: String = layout.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect();
// Windows reserves device names (`CON`, `NUL`, `COM1`...) with any extension, so prefix them.
let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
let reserved = matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (base.len() == 4 && (base.starts_with("COM") || base.starts_with("LPT")) && base.ends_with(|c: char| ('1'..='9').contains(&c)));
let file = if reserved { format!("_{name}.dwt") } else { format!("{name}.dwt") };
let candidate = match s.state().ok().and_then(|st| st.path.clone()) {
Some(p) => std::path::Path::new(&p).with_file_name(&file),
None => std::path::PathBuf::from(file),
};
if !candidate.exists() {
return candidate.to_string_lossy().into_owned();
}
let stem = candidate.file_stem().and_then(|s| s.to_str()).unwrap_or("layout");
let extension = candidate.extension().and_then(|e| e.to_str()).unwrap_or("dwt");
let parent = candidate.parent().unwrap_or_else(|| std::path::Path::new(""));
// The suffix range never ends in practice; the fallback only keeps the code panic-free.
let alternative = (2u64..)
.map(|suffix| parent.join(format!("{stem}-{suffix}.{extension}")))
.find(|alternative| !alternative.exists())
.unwrap_or(candidate.clone());
alternative.to_string_lossy().into_owned()
}
/// `path` with `.dwt` added when it has no extension; an error for any other file type.
@@ -133,3 +152,32 @@ fn store(path: &str, bytes: &[u8]) -> Result<()> {
super::super::exchange::deliver_file("layout.saveas", path, bytes, super::super::file::deliver())
}
}
#[cfg(test)]
mod default_path_tests {
use super::*;
/// A layout name with separators suggests a file beside the drawing, not a path elsewhere.
#[test]
fn layout_name_is_one_file_name() {
let s = Session::empty();
for name in ["../../etc/x", "a/b", "a\\b", "C:x"] {
let p = default_path(&s, name);
assert!(!p.contains('/') && !p.contains('\\') && !p.contains(':'), "{p}");
assert!(p.ends_with(".dwt"));
}
assert_eq!(default_path(&s, "Layout1"), "Layout1.dwt");
}
/// Windows device names are prefixed so the suggestion is never a device.
#[test]
fn reserved_device_names_are_prefixed() {
let s = Session::empty();
for name in ["CON", "nul", "Com1", "LPT9", "aux.x"] {
assert_eq!(default_path(&s, name), format!("_{name}.dwt"));
}
for name in ["COM0", "CONSOLE", "COM10"] {
assert_eq!(default_path(&s, name), format!("{name}.dwt"));
}
}
}
+3 -2
View File
@@ -108,8 +108,9 @@ fn property(d: &Drawing, e: &Entity, name: &str) -> Option<Value> {
/// Glob match, case-insensitive: `*` any run, `?` one character, `#` one digit.
pub fn wildcard(pat: &str, text: &str) -> bool {
let p: Vec<char> = pat.to_lowercase().chars().take(1024).collect();
let t: Vec<char> = text.to_lowercase().chars().take(4096).collect();
// Only the characters that can match are lower-cased, so a huge text isn't copied whole.
let p: Vec<char> = pat.chars().take(1024).flat_map(char::to_lowercase).collect();
let t: Vec<char> = text.chars().take(4096).flat_map(char::to_lowercase).collect();
// Iterative matcher with single back-track point (linear-ish, no recursion).
let (mut pi, mut ti) = (0usize, 0usize);
let mut star: Option<(usize, usize)> = None;
+47 -4
View File
@@ -27,8 +27,14 @@ pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError>
if !writing {
match read_text_file(&path) {
Some(t) => {
charge_bytes(t.len())?;
f.lines = t.lines().map(str::to_string).collect();
// Each line has a String header and a separately allocated buffer. Charge a
// conservative 64 bytes per line for both, including the allocator's minimum
// allocation size, before collecting so many short lines cannot evade the budget.
let count = t.lines().count();
charge_bytes(t.len().saturating_add(count.saturating_mul(64)))?;
// Exact capacity: collecting would grow by doubling and hold unbilled slack.
f.lines = std::collections::VecDeque::with_capacity(count);
f.lines.extend(t.lines().map(str::to_string));
}
None => return Ok(Flow::Value(Value::Nil)),
}
@@ -100,8 +106,19 @@ fn can_write(_path: &str) -> bool {
#[cfg(not(target_arch = "wasm32"))]
fn write_text_file(path: &str, text: &str, append: bool) {
use std::io::Write;
let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path);
if let Ok(mut f) = f {
// Opened without blocking and without truncating, so a FIFO with no reader fails at once
// instead of stalling the interpreter; only a regular file is then truncated and written.
let mut o = std::fs::OpenOptions::new();
o.create(true).write(true).append(append);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
o.custom_flags(libc::O_NONBLOCK);
}
if let Ok(mut f) = o.open(path)
&& f.metadata().is_ok_and(|m| m.is_file())
&& (append || f.set_len(0).is_ok())
{
let _ = f.write_all(text.as_bytes());
}
}
@@ -118,3 +135,29 @@ fn file_exists(path: &str) -> bool {
fn file_exists(_path: &str) -> bool {
false
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
/// Writing to a FIFO with no reader returns at once, and a regular file is still replaced.
#[test]
fn write_to_a_fifo_without_a_reader_does_not_block() {
// A fresh directory: `create_dir` fails if the name exists, so no stale pipe or planted symlink is reused.
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("cadcraft-lispfifo-{}-{nanos}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
let fifo = dir.join("pipe");
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
let started = std::time::Instant::now();
write_text_file(fifo.to_str().unwrap(), "x", false);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
let f = dir.join("f.txt");
std::fs::write(&f, "long old text").unwrap();
write_text_file(f.to_str().unwrap(), "new", false);
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new");
write_text_file(f.to_str().unwrap(), "+", true);
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new+");
std::fs::remove_dir_all(&dir).ok();
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Saving an edited block as a copy must retain its source's last saved definition.
use cadcraft_engine::Session;
use serde_json::json;
/// Add a third line to a block and BSAVE it, BSAVEAS a copy, then edit and save the copy: the
/// source block keeps the definition of its own last save.
#[test]
fn save_as_preserves_the_last_saved_source_definition() {
let mut s = Session::new();
let mut handles = Vec::new();
for y in [5, 9] {
let line = s.execute("line", &json!({ "points": [[5, y], [15, y]] })).unwrap();
handles.push(line["handles"][0].clone());
}
s.execute("block", &json!({ "name": "Frame", "base": [5, 5], "handles": handles, "keep": "delete" })).unwrap();
s.execute("insert", &json!({ "name": "Frame", "at": [100, 20] })).unwrap();
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 2);
s.execute("line", &json!({ "points": [[0, 3], [3, 3]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
let saved_source = s.state().unwrap().drawing().block("Frame").unwrap().as_ref().clone();
assert_eq!(saved_source.entities.len(), 3);
s.execute("bsaveas", &json!({ "name": "Copy" })).unwrap();
assert_eq!(s.state().unwrap().block_editor(), Some("Copy"));
assert_eq!(s.state().unwrap().drawing().block("Frame").unwrap().as_ref(), &saved_source, "copy composition keeps the saved source");
// A later save of Copy must not reintroduce the editor table's older source definition.
s.execute("line", &json!({ "points": [[0, 6], [4, 6]] })).unwrap();
s.execute("bsave", &json!({})).unwrap();
s.execute("bclose", &json!({ "save": false })).unwrap();
assert_eq!(s.doc().unwrap().block("Frame").unwrap().as_ref(), &saved_source, "editor reconciliation keeps the saved source");
assert_eq!(s.doc().unwrap().block("Copy").unwrap().entities.len(), 4, "the copy keeps its own later edit");
s.execute("bedit", &json!({ "name": "Frame" })).unwrap();
assert_eq!(s.doc().unwrap().model.len(), 3);
}
+22
View File
@@ -190,6 +190,28 @@ fn reload_unload_path_and_detach() {
assert_eq!(objects(&s, "site"), 3, "reload reads the file again");
assert!(s.doc().unwrap().layer("site|Walls").unwrap().frozen);
// `exact`: a name is literal, so `#` or `*` in it doesn't reach other xrefs.
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "s#te", "exact": true })).is_err());
assert!(s.execute("-xref", &json!({ "option": "reload", "names": "*", "exact": true })).is_err());
s.execute("-xref", &json!({ "option": "reload", "names": "SITE", "exact": true })).unwrap();
// Two xrefs that differ only by case: an exact selection of both is refused before anything loads,
// and reload-all loads one of them once.
let mut twin = Block::new("SITE");
twin.xref_path = Some("site.dxf".into());
s.doc_mut().unwrap().blocks.insert("SITE".into(), Arc::new(twin));
assert!(s.execute("-xref", &json!({ "option": "reload", "names": ["site", "SITE"], "exact": true })).is_err());
let r = s.execute("-xref", &json!({ "option": "reload" })).unwrap();
assert_eq!(r["xrefs"].as_array().map(Vec::len), Some(1));
// Detaching one of them, or giving both a new path, is refused before anything changes.
assert!(s.execute("-xref", &json!({ "option": "detach", "names": "site", "exact": true })).is_err());
assert!(s.doc().unwrap().block("site").is_some());
assert!(s.execute("-xref", &json!({ "option": "path", "names": "*", "path": "other.dxf" })).is_err());
for k in ["site", "SITE"] {
assert_eq!(s.doc().unwrap().blocks.get(k).and_then(|b| b.xref_path.clone()).as_deref(), Some("site.dxf"), "{k}");
}
s.doc_mut().unwrap().blocks.remove("SITE");
s.execute("-xref", &json!({ "option": "unload", "names": "site" })).unwrap();
assert_eq!((status(&s, "site"), objects(&s, "site")), (Some(XrefStatus::Unloaded), 0));
assert!(s.doc().unwrap().block("site|Tree").is_none());
+28 -3
View File
@@ -43,13 +43,16 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
rerun_if_changed(&manifest)?;
// Resolve the checkout root once so every manifest file can be confined inside it.
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
// The manifest itself is confined too (a symlink in the checkout can't point it elsewhere), must
// be a regular file, and is read through a limit so `/dev/zero` can't fill memory.
let text = read_manifest(&resolve_font(&root, "fonts/manifest.txt")?)?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
for (n, line) in text.lines().enumerate().map(|(n, l)| (n + 1, l.trim())).filter(|(_, l)| !l.is_empty() && !l.starts_with('#')) {
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
// The line's text is not repeated: it comes from the checkout and goes to the build output.
let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}"));
return Err(format!("malformed manifest line {n}"));
};
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md: no Adobe fonts)");
@@ -73,6 +76,28 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
Ok(out)
}
/// The most a manifest may hold; the real one is a few KiB.
const MAX_MANIFEST_BYTES: u64 = 1 << 20;
/// Read the regular file `path` as text, at most [`MAX_MANIFEST_BYTES`].
fn read_manifest(path: &std::path::Path) -> Result<String, String> {
use std::io::Read as _;
// stat never blocks, but opening a FIFO with no writer does, so refuse non-regular files first.
if !std::fs::metadata(path).is_ok_and(|m| m.is_file()) {
return Err(format!("{}: not a regular file", path.display()));
}
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
if !file.metadata().is_ok_and(|m| m.is_file()) {
return Err(format!("{}: not a regular file", path.display()));
}
let mut text = String::new();
file.take(MAX_MANIFEST_BYTES + 1).read_to_string(&mut text).map_err(|e| format!("{}: {e}", path.display()))?;
if text.len() as u64 > MAX_MANIFEST_BYTES {
return Err(format!("{}: larger than {MAX_MANIFEST_BYTES} bytes", path.display()));
}
Ok(text)
}
/// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22).
///
/// The field is rejected if it is absolute or contains a parent (`..`) component, and the
+10 -1
View File
@@ -184,6 +184,9 @@ pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, b
let mut under_start: Option<f64> = None;
let mut over_start: Option<f64> = None;
let mut gap = stroke::GAP * sc * wf;
// Retained fallback-outline points over the line, as the TrueType path caps them: a short string
// repeating one detailed glyph would otherwise copy its outline per occurrence.
let mut points_left = ttf::MAX_SHAPED_POINTS;
// The simple path lays a line out left to right: one asked to run right to left goes through
// the bidi path even without right-to-left letters.
if (complex::needed(s) || base_rtl == Some(true))
@@ -202,7 +205,13 @@ pub fn shape_stroke_dir(s: &str, height: f64, width_factor: f64, oblique: f64, b
None if !c.is_whitespace() => match ttf::fallback_glyph(c) {
Some(g) => {
let (y, scale) = stroke_fallback_metrics(&g, c, h);
let contours = ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear);
let contours = match points_left.checked_sub(g.points) {
Some(rest) => {
points_left = rest;
ttf::place_glyph_at(&g, Vec2::new(x, y), scale, wf, shear)
}
None => Vec::new(),
};
if !contours.is_empty() {
out.push_glyph(contours, ttf::text_glyph_at(&g, c, Vec2::new(x + y * shear, y), scale, wf, shear));
}
+54
View File
@@ -153,6 +153,40 @@ pub struct MTextLayout {
pub bounds: Bounds2,
/// Plain text of each laid-out line.
pub lines: Vec<String>,
/// Whether the geometry stops short: the layout reached its allowance of retained points
/// ([`Allowance`]) and the segments after that are not drawn.
pub truncated: bool,
}
/// The points all the segments of one layout may keep together, each counted for both copies the
/// layout holds: the allowance one shaped line has (`MAX_SHAPED_POINTS`), so a short repeated detailed glyph over many segments cannot multiply it.
pub(crate) struct Allowance(usize);
impl Allowance {
pub(crate) fn new() -> Self {
Allowance(crate::ttf::MAX_SHAPED_POINTS)
}
/// Whether any allowance is left, before a segment is shaped.
pub(crate) fn left(&self) -> bool {
self.0 > 0
}
/// Charge the points of `sh`; false (and nothing left) when it does not fit.
pub(crate) fn charge(&mut self, sh: &Shaped) -> bool {
// Kept twice: in the piece and again in the layout's flat `strokes`.
let n = 2 * (sh.strokes.iter().map(Vec::len).sum::<usize>() + sh.glyphs.iter().flatten().map(Vec::len).sum::<usize>());
match self.0.checked_sub(n) {
Some(rest) => {
self.0 = rest;
true
}
None => {
self.0 = 0;
false
}
}
}
}
/// Inline character format.
@@ -850,6 +884,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
let insert = p.insert;
let xf = |q: Vec2| insert + q.rotate(rotation);
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
let mut allowance = Allowance::new();
for (l, base_y) in lines.iter().zip(baselines) {
let x0 = match col {
0 => 0.0,
@@ -859,7 +894,15 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
for it in &l.items {
let Some(sg) = &it.seg else { continue };
let fm = fmt(it.fmt);
if !allowance.left() {
out.truncated = true;
continue;
}
let mut sh = seg_shape(sg, fm, it.x);
if !allowance.charge(&sh) {
out.truncated = true;
continue;
}
let off = Vec2::new(x0, top + base_y);
sh.map(|q| xf(q + off));
match out.pieces.last_mut() {
@@ -896,6 +939,17 @@ mod tests {
assert_eq!(l.lines[0], "alpha beta gamma");
}
/// The points of all the segments of one layout are charged against one allowance.
#[test]
fn a_layout_shares_one_point_allowance() {
let big = Shaped { strokes: vec![vec![Vec2::ZERO; crate::ttf::MAX_SHAPED_POINTS / 4 + 1]], ..Default::default() };
let mut a = Allowance::new();
assert!(a.left() && a.charge(&big), "the first segment fits");
assert!(!a.charge(&big), "the second does not, though each alone would");
assert!(!a.left(), "and nothing is left to shape another");
assert!(!layout_mtext("plain text", Vec2::ZERO, 1.0, 0.0, 1, 0.0, 1.0).truncated);
}
#[test]
fn strips_formatting() {
assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2");
+9
View File
@@ -677,6 +677,7 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
let insert = p.insert;
let xf = |q: Vec2| insert + q.rotate(rotation);
let mut out = MTextLayout { lines: lines.iter().map(|l| l.text.clone()).collect(), ..Default::default() };
let mut allowance = super::Allowance::new();
for (l, (c, base_y)) in lines.iter().zip(placed) {
let pp = paras.props(l.para);
let slot = if reversed { n_cols.saturating_sub(1).saturating_sub(c) } else { c };
@@ -696,7 +697,15 @@ pub(super) fn layout(contents: &str, p: &MTextParams, base: &Fmt) -> MTextLayout
};
for it in &l.items {
let fm = fmt(it.fmt);
if !allowance.left() {
out.truncated = true;
continue;
}
let mut sh = seg_shape(&it.seg, fm, it.x);
if !allowance.charge(&sh) {
out.truncated = true;
continue;
}
let off = Vec2::new(col_x + shift + per_gap * it.gap as f64, top + base_y);
sh.map(|q| xf(q + off));
match out.pieces.last_mut() {
+1 -1
View File
@@ -932,7 +932,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
#[derive(Default)]
pub(crate) struct GlyphOutline {
pub(crate) contours: Arc<[Vec<Vec2>]>,
points: usize,
pub(crate) points: usize,
pub(crate) advance: f64,
pub(crate) cap: f64,
/// The font maps the character (false: the missing-glyph shape).
+17 -5
View File
@@ -161,13 +161,25 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
/// Every `.rs` file under `dir`, at any depth, so code split into submodules stays scanned.
fn module_files(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
if !std::fs::symlink_metadata(dir).is_ok_and(|metadata| metadata.file_type().is_dir()) {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
for path in paths {
if path.is_dir() {
// `file_type` doesn't follow links, so a symlink (a directory alias or a file) is skipped,
// not walked again as a new subtree.
// An unreadable entry must fail the scan, not silently shrink the translation report.
let mut paths: Vec<_> = entries
.map(|e| {
let e = e.expect("read module directory entry");
let kind = e.file_type().expect("read module entry type");
(e.path(), kind)
})
.collect();
paths.sort_by(|a, b| a.0.cmp(&b.0));
for (path, kind) in paths {
if kind.is_dir() {
module_files(&path, files);
} else if path.extension().is_some_and(|x| x == "rs") {
} else if kind.is_file() && path.extension().is_some_and(|x| x == "rs") {
files.push(path);
}
}
+4 -4
View File
@@ -197,7 +197,7 @@ pub fn section(app: &mut CadApp, ui: &mut egui::Ui) {
fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>) {
let name = r.name.clone();
let loaded = r.status == "Loaded" || r.status == "Unreferenced";
let op = |option: &str| json!({ "option": option, "names": name });
let op = |option: &str| json!({ "option": option, "names": name, "exact": true });
ui.horizontal_wrapped(|ui| {
ui.add_space(8.0);
if ui.button(crate::tl!("Reload")).clicked() {
@@ -221,7 +221,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
.on_hover_text(crate::tl!("Make it a block of this drawing, keeping its layer names"))
.clicked()
{
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "insert": true })));
*act = Some(Act::Run("-xref", json!({ "option": "bind", "names": name, "exact": true, "insert": true })));
}
if ui.add_enabled(r.status != "Not Found", egui::Button::new(crate::tl!("Open"))).clicked() {
*act = Some(Act::Run("xopen", json!({ "name": name })));
@@ -235,7 +235,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
let a = ui.radio_value(&mut overlay, false, crate::tl!("Attach")).clicked();
let o = ui.radio_value(&mut overlay, true, crate::tl!("Overlay")).clicked();
if (a || o) && overlay != r.overlay {
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "overlay": overlay })));
*act = Some(Act::Run("-xref", json!({ "option": "type", "names": name, "exact": true, "overlay": overlay })));
}
});
ui.add_space(4.0);
@@ -248,7 +248,7 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option<Act>)
let apply = ui.add_enabled(!text.trim().is_empty() && text != r.path, egui::Button::new(crate::tl!("Apply"))).clicked()
|| (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && text != r.path);
if apply {
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "path": text.trim() })));
*act = Some(Act::Run("-xref", json!({ "option": "path", "names": name, "exact": true, "path": text.trim() })));
ui.data_mut(|d| d.remove::<String>(path_id));
} else if text != r.path {
ui.data_mut(|d| d.insert_temp(path_id, text));
+27
View File
@@ -0,0 +1,27 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.cadcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
@@ -17,6 +17,8 @@ runtime-version: '26.08'
sdk: org.freedesktop.Sdk
command: cadcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -18,6 +18,8 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: cadcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
+14 -4
View File
@@ -136,6 +136,18 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
mod tests {
use super::{Counts, count_tests};
/// A new private directory in the temp folder. It is created, never reused: an existing path
/// (planted by another user, or a symlink) makes the test fail instead of redirecting its writes.
fn fresh_dir(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
let p = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{nanos}", std::process::id()));
let mut b = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
b.create(&p).unwrap();
p
}
#[test]
fn counts_attributes_and_proptest_blocks() {
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
@@ -147,8 +159,7 @@ mod tests {
#[test]
fn walk_does_not_follow_directory_symlinks() {
use std::os::unix::fs::symlink;
let base = std::env::temp_dir().join(format!("cadcraft-xtask-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let base = fresh_dir("xtask");
let real = base.join("real");
std::fs::create_dir_all(&real).unwrap();
std::fs::write(real.join("a.rs"), "fn a() {}\n").unwrap();
@@ -162,8 +173,7 @@ mod tests {
/// Traversal reports an exhausted entry budget and accepts a tree that exactly fits it.
#[test]
fn walk_reports_budget_exhaustion() {
let base = std::env::temp_dir().join(format!("cadcraft-stats-budget-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let base = fresh_dir("stats-budget");
std::fs::write(base.join("a.rs"), "fn a() {}\n").unwrap();
let error = super::walk_inner(&base, &mut Counts::default(), &mut 0).unwrap_err();
assert!(error.contains("budget exhausted"));