mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-12 00:45:16 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a204c63c40 | ||
|
|
5a6aa9b65f | ||
|
|
b460a3b8d9 | ||
|
|
cb96c182ef | ||
|
|
e1d379ad9f | ||
|
|
dc7f382cec | ||
|
|
e5021f0175 | ||
|
|
2d25308cfc | ||
|
|
20b4472f0b | ||
|
|
ecb362aa55 | ||
|
|
f132d81609 | ||
|
|
650ae383f2 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -645,7 +645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -661,14 +661,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -678,7 +678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -689,7 +689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
@@ -697,7 +697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -706,7 +706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -727,7 +727,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-geom",
|
||||
@@ -740,14 +740,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -769,7 +769,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -784,7 +784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -798,7 +798,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -825,7 +825,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.20.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.20.0"
|
||||
version = "0.20.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.20.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 }
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -19,21 +19,6 @@ const MAX_TABLE_ROWS: usize = 2000;
|
||||
const MAX_TABLE_COLS: usize = 200;
|
||||
/// Attribute columns kept at most.
|
||||
const MAX_TAGS: usize = 190;
|
||||
/// Attribute text one extraction may hold across all its references (each row owns a copy of its
|
||||
/// values, constants included). Far above any real schedule; an error, never a silent cut.
|
||||
const MAX_VALUE_BYTES: usize = 1 << 30;
|
||||
|
||||
/// Add `add` bytes to `used`; an error past `limit`.
|
||||
fn charge(used: &mut usize, add: usize, limit: usize) -> Result<()> {
|
||||
*used = used.saturating_add(add);
|
||||
if *used > limit {
|
||||
return Err(bad(
|
||||
"dataextraction",
|
||||
format!("the attribute text of the block references passes {} MiB; narrow `blocks`, `tags` or `handles`", limit >> 20),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const PARAMS: &str = "{blocks?: \"A*,B\" (block names, wildcards, default *), tags?: attribute tags to include (wildcards, default *), handles? (default: the block references in model space and every layout), combine?: bool (default true: identical rows once, with a Count column; false: one row per reference with handle, layer, position and rotation), file?: path (.csv; .txt is tab-separated), at?: [x,y] (insert a table there), title?: table title} → {columns, rows, file?, table?}";
|
||||
|
||||
@@ -182,8 +167,6 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
};
|
||||
let selected = selected_tags(&ents, d, o);
|
||||
let mut out = Vec::new();
|
||||
let mut used = 0usize;
|
||||
let mut const_sizes: HashMap<String, Vec<(String, usize)>> = HashMap::new();
|
||||
let mut consts: HashMap<String, Vec<(String, String)>> = HashMap::new();
|
||||
for e in &ents {
|
||||
let EntityKind::Insert(ins) = &e.kind else { continue };
|
||||
@@ -192,47 +175,18 @@ fn found(s: &Session, p: &Value, o: &Options) -> Result<Vec<Found>> {
|
||||
continue;
|
||||
}
|
||||
let block = d.block(&ins.block).map(|b| b.name.clone()).unwrap_or_else(|| ins.block.clone());
|
||||
// Charged from the lengths, before any text is copied.
|
||||
let selected_bytes: usize =
|
||||
ins.attribs.iter().filter(|a| selected.contains(&a.tag.to_ascii_uppercase())).map(|a| a.text.value.len() + a.tag.len()).sum();
|
||||
// The constants this row copies (those its own attributes do not override), from the lengths.
|
||||
let first_of_block = !const_sizes.contains_key(&block);
|
||||
let consts_sizes = const_sizes.entry(block.clone()).or_insert_with(|| {
|
||||
d.block(&ins.block).map_or_else(Vec::new, |b| {
|
||||
b.entities
|
||||
.iter()
|
||||
.filter_map(|e| match &e.kind {
|
||||
EntityKind::AttDef(a) if a.constant && selected.iter().any(|t| t == &a.tag.to_ascii_uppercase()) => {
|
||||
Some((a.tag.to_ascii_uppercase(), a.tag.len() + a.text.value.len()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
});
|
||||
if first_of_block {
|
||||
// The per-block cache of constants is one more copy of them, made once.
|
||||
charge(&mut used, consts_sizes.iter().map(|(_, n)| *n).fold(0, usize::saturating_add), MAX_VALUE_BYTES)?;
|
||||
}
|
||||
let const_bytes: usize = consts_sizes
|
||||
.iter()
|
||||
.filter(|(tag, _)| !ins.attribs.iter().any(|a| a.tag.eq_ignore_ascii_case(tag)))
|
||||
.map(|(_, n)| *n)
|
||||
.fold(0, usize::saturating_add);
|
||||
charge(&mut used, selected_bytes.saturating_add(const_bytes), MAX_VALUE_BYTES)?;
|
||||
let values = values(
|
||||
ins,
|
||||
// Keyed by the resolved definition's exact name: `Part` and `PART` can both exist.
|
||||
consts.entry(block.clone()).or_insert_with(|| d.block(&ins.block).map(|b| constants(b, &selected)).unwrap_or_default()),
|
||||
&selected,
|
||||
);
|
||||
out.push(Found {
|
||||
handle: e.handle.hex(),
|
||||
layer: e.common.layer.clone(),
|
||||
at: ins.insert.xy(),
|
||||
rotation: ins.rotation.to_degrees(),
|
||||
block: block.clone(),
|
||||
values,
|
||||
values: values(
|
||||
ins,
|
||||
// Keyed by the resolved definition's exact name: `Part` and `PART` can both exist.
|
||||
consts.entry(block.clone()).or_insert_with(|| d.block(&ins.block).map(|b| constants(b, &selected)).unwrap_or_default()),
|
||||
&selected,
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
@@ -495,15 +449,6 @@ impl Interactive for ExtractM {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn attribute_text_is_charged_against_one_budget() {
|
||||
let mut used = 0;
|
||||
assert!(super::charge(&mut used, 60, 100).is_ok());
|
||||
assert!(super::charge(&mut used, 40, 100).is_ok());
|
||||
assert!(super::charge(&mut used, 1, 100).is_err());
|
||||
assert!(super::charge(&mut used, usize::MAX, 100).is_err());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! of nested xrefs (`xref|nested|name`) bind with every `|` written `$0$`.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_doc::{Drawing, EntityStore, MAX_BLOCK_DEPTH, XrefStatus};
|
||||
@@ -20,16 +20,6 @@ fn split(name: &str) -> Option<(&str, &str)> {
|
||||
|
||||
/// Whether table `sym` of `d` has a symbol called `name`.
|
||||
fn taken(d: &Drawing, sym: Sym, name: &str) -> bool {
|
||||
taken_with(d, sym, name, None)
|
||||
}
|
||||
|
||||
/// [`taken`], answering for blocks from `blocks` (the upper-cased names of `d`'s blocks, kept
|
||||
/// current by the caller) when given: a lookup that misses scans every block, which a bind
|
||||
/// naming thousands of blocks would repeat for each.
|
||||
fn taken_with(d: &Drawing, sym: Sym, name: &str, blocks: Option<&HashSet<String>>) -> bool {
|
||||
if let (Sym::Block, Some(set)) = (sym, blocks) {
|
||||
return set.contains(&name.to_ascii_uppercase());
|
||||
}
|
||||
match sym {
|
||||
Sym::Layer => d.layer(name).is_some(),
|
||||
Sym::Linetype => d.linetype(name).is_some(),
|
||||
@@ -42,12 +32,12 @@ fn taken_with(d: &Drawing, sym: Sym, name: &str, blocks: Option<&HashSet<String>
|
||||
}
|
||||
|
||||
/// The bound name of `key|rest`: `key$n$rest` with the first free `n`, or `rest` (Insert type).
|
||||
fn bound_name(d: &Drawing, sym: Sym, key: &str, rest: &str, insert: bool, blocks: Option<&HashSet<String>>) -> String {
|
||||
fn bound_name(d: &Drawing, sym: Sym, key: &str, rest: &str, insert: bool) -> String {
|
||||
let rest = rest.replace('|', "$0$");
|
||||
if insert {
|
||||
return rest;
|
||||
}
|
||||
(0..10_000).map(|n| format!("{key}${n}${rest}")).find(|c| !taken_with(d, sym, c, blocks)).unwrap_or_else(|| format!("{key}$${rest}"))
|
||||
(0..10_000).map(|n| format!("{key}${n}${rest}")).find(|c| !taken(d, sym, c)).unwrap_or_else(|| format!("{key}$${rest}"))
|
||||
}
|
||||
|
||||
/// Every table's symbol names, for one table at a time.
|
||||
@@ -63,8 +53,9 @@ fn names(d: &Drawing, sym: Sym) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename symbol `old` of table `sym` to `new`, or drop it (`merge`: `new` already exists, Insert type).
|
||||
fn rename_symbol(d: &mut Drawing, sym: Sym, old: &str, new: &str, merge: bool) {
|
||||
/// Rename symbol `old` of table `sym` to `new`, or drop it (`new` already exists: Insert type).
|
||||
fn rename_symbol(d: &mut Drawing, sym: Sym, old: &str, new: &str) {
|
||||
let merge = !new.eq_ignore_ascii_case(old) && taken(d, sym, new);
|
||||
macro_rules! table {
|
||||
($v:expr) => {{
|
||||
if merge {
|
||||
@@ -110,17 +101,12 @@ pub(crate) fn bind(d: &mut Drawing, key: &str, insert: bool) -> Result<(), Strin
|
||||
let mut map: HashMap<(Sym, String), String> = HashMap::new();
|
||||
let mut bound_blocks = vec![key.clone()];
|
||||
for sym in TABLES {
|
||||
let mut blocks: Option<HashSet<String>> = (sym == Sym::Block).then(|| d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect());
|
||||
for old in names(d, sym).into_iter().filter(|n| has_prefix(n, &prefix)) {
|
||||
let rest = old.get(prefix.len()..).unwrap_or_default();
|
||||
let new = bound_name(d, sym, &key, rest, insert, blocks.as_ref());
|
||||
let new = bound_name(d, sym, &key, rest, insert);
|
||||
// Insert type: a host symbol of the bound name is used instead of the xref's.
|
||||
let merged = taken_with(d, sym, &new, blocks.as_ref());
|
||||
rename_symbol(d, sym, &old, &new, !new.eq_ignore_ascii_case(&old) && merged);
|
||||
if let Some(set) = &mut blocks {
|
||||
set.remove(&old.to_ascii_uppercase());
|
||||
set.insert(new.to_ascii_uppercase());
|
||||
}
|
||||
let merged = taken(d, sym, &new);
|
||||
rename_symbol(d, sym, &old, &new);
|
||||
if sym == Sym::Block && !merged {
|
||||
bound_blocks.push(new.clone());
|
||||
}
|
||||
@@ -256,27 +242,14 @@ fn xbind_in(d: &mut Drawing, sym: Sym, name: &str, depth: usize) -> Result<Strin
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_block_name_set_answers_as_the_scan_does() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
for n in ["SITE$0$Plan", "Other"] {
|
||||
d.blocks.insert(n.into(), Arc::new(cadcraft_doc::Block::new(n)));
|
||||
}
|
||||
let set: HashSet<String> = d.blocks.keys().map(|k| k.to_ascii_uppercase()).collect();
|
||||
for n in ["site$0$plan", "SITE$0$Plan", "OTHER", "SITE$1$Plan", "nothing"] {
|
||||
assert_eq!(taken_with(&d, Sym::Block, n, Some(&set)), taken(&d, Sym::Block, n), "{n}");
|
||||
}
|
||||
assert_eq!(bound_name(&d, Sym::Block, "SITE", "plan", false, Some(&set)), "SITE$1$plan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_names() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", false, None), "SITE$0$Walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", false), "SITE$0$Walls");
|
||||
d.ensure_layer("SITE$0$Walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "walls", false, None), "SITE$1$walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", true, None), "Walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "TREES|Oak", false, None), "SITE$0$TREES$0$Oak");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "walls", false), "SITE$1$walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "Walls", true), "Walls");
|
||||
assert_eq!(bound_name(&d, Sym::Layer, "SITE", "TREES|Oak", false), "SITE$0$TREES$0$Oak");
|
||||
assert_eq!(split("A|b|c"), Some(("A", "b|c")));
|
||||
assert_eq!(split("|b"), None);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -497,30 +497,6 @@ fn frame_books(d: &Drawing, frame_height: f64, unit: f64) -> (Vec<Vec<Frame>>, u
|
||||
(books, skipped)
|
||||
}
|
||||
|
||||
/// Most page-by-entity renderings one PDFBOOK may ask for: every page draws the whole model through
|
||||
/// its window, so the work is the frames times the model's objects (1,000 sheets of a 500,000-object
|
||||
/// drawing).
|
||||
const MAX_BOOK_WORK: u64 = 500_000_000;
|
||||
|
||||
/// Objects one page draws: the model's objects, and the contents of each block reference (once per
|
||||
/// array cell, and the reference's own attributes; nested blocks count as their own entries).
|
||||
fn model_visits(d: &Drawing) -> usize {
|
||||
d.model
|
||||
.iter()
|
||||
.map(|e| match &e.kind {
|
||||
EntityKind::Insert(i) => {
|
||||
d.block(&i.block).map_or(1, |b| 1 + b.entities.len().saturating_mul((i.rows as usize).saturating_mul(i.cols as usize)))
|
||||
}
|
||||
_ => 1,
|
||||
})
|
||||
.fold(0usize, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// Whether `frames` pages of a model with `entities` objects stay within [`MAX_BOOK_WORK`].
|
||||
fn book_work_fits(frames: usize, entities: usize) -> bool {
|
||||
(frames as u64).saturating_mul(entities as u64) <= MAX_BOOK_WORK
|
||||
}
|
||||
|
||||
fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if io().and_then(|h| h.plot).is_none() {
|
||||
return Err(bad("pdfbook", "plotting is not available in this build"));
|
||||
@@ -531,13 +507,6 @@ fn run_pdfbook(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if books.is_empty() {
|
||||
return Err(bad("pdfbook", format!("no closed polylines {frame_height} mm high (sheet frames) in model space")));
|
||||
}
|
||||
let sheets: usize = books.iter().map(Vec::len).sum();
|
||||
if !book_work_fits(sheets, model_visits(s.doc()?)) {
|
||||
return Err(bad(
|
||||
"pdfbook",
|
||||
format!("{sheets} sheets of a model this large are too much work for one command; plot part of the drawing at a time"),
|
||||
));
|
||||
}
|
||||
let target = str_param(p, "path").map(str::to_string).or_else(|| s.state().ok().and_then(|st| st.path.clone()));
|
||||
let pb = target.as_deref().map(std::path::Path::new);
|
||||
let dir = pb.and_then(std::path::Path::parent).map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
@@ -592,13 +561,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_is_bounded_by_pages_times_objects() {
|
||||
assert!(book_work_fits(1_000, 500_000));
|
||||
assert!(!book_work_fits(100_000, 100_000));
|
||||
assert!(!book_work_fits(usize::MAX, usize::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_export_delivers_a_download_instead_of_writing_a_path() {
|
||||
deliver_file("export", "Drawing1.svg", b"<svg/>", Some(record)).unwrap();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,32 +7,16 @@ use super::{
|
||||
// NCOPY
|
||||
// =====================================================================================
|
||||
|
||||
/// Block entities one NCOPY may visit in all, leaves or not: references to shared, empty or
|
||||
/// repeated sub-blocks multiply the visits (not the leaves) with each level of nesting.
|
||||
const MAX_VISITS: usize = 1_000_000;
|
||||
|
||||
/// The leaf objects of a block reference in world coordinates (nested references expanded). An
|
||||
/// error when the nesting needs more than [`MAX_VISITS`] visits.
|
||||
fn nested_leaves(
|
||||
d: &cadcraft_doc::Drawing,
|
||||
ins: &cadcraft_doc::Insert,
|
||||
m: &Mat3,
|
||||
depth: usize,
|
||||
visits: &mut usize,
|
||||
out: &mut Vec<(Common, EntityKind)>,
|
||||
) -> Result<()> {
|
||||
/// The leaf objects of a block reference in world coordinates (nested references expanded).
|
||||
fn nested_leaves(d: &cadcraft_doc::Drawing, ins: &cadcraft_doc::Insert, m: &Mat3, depth: usize, out: &mut Vec<(Common, EntityKind)>) {
|
||||
if depth >= MAX_BLOCK_DEPTH || out.len() >= MAX_GEN {
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
let Some(b) = d.block(&ins.block) else { return Ok(()) };
|
||||
let Some(b) = d.block(&ins.block) else { return };
|
||||
let mm = m.then_before(ins.transform(b.base.xy()));
|
||||
for e in b.entities.iter() {
|
||||
*visits += 1;
|
||||
if *visits > MAX_VISITS {
|
||||
return Err(other("The block reference nests too many objects to copy."));
|
||||
}
|
||||
match &e.kind {
|
||||
EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, visits, out)?,
|
||||
EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, out),
|
||||
EntityKind::AttDef(_) => {}
|
||||
k => {
|
||||
let mut k = k.clone();
|
||||
@@ -41,14 +25,13 @@ fn nested_leaves(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn leaves_of(s: &Session, h: Handle, visits: &mut usize) -> Result<Vec<(Common, EntityKind)>> {
|
||||
fn leaves_of(s: &Session, h: Handle) -> Result<Vec<(Common, EntityKind)>> {
|
||||
let e = curves::entity(s, h)?;
|
||||
let EntityKind::Insert(ins) = &e.kind else { return Err(other("Select a block reference.")) };
|
||||
let mut out = Vec::new();
|
||||
nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, visits, &mut out)?;
|
||||
nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -70,7 +53,7 @@ fn place_copies(s: &mut Session, items: &[(Common, EntityKind)], delta: Vec2) ->
|
||||
|
||||
pub(super) fn run_ncopy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("ncopy", "`handle` (block reference) is required"))?;
|
||||
let leaves = leaves_of(s, h, &mut 0)?;
|
||||
let leaves = leaves_of(s, h)?;
|
||||
let items: Vec<(Common, EntityKind)> = match point_param(p, "pick") {
|
||||
Some(q) => nearest_leaf(leaves, q).into_iter().collect(),
|
||||
None => leaves,
|
||||
@@ -91,8 +74,6 @@ pub(super) fn run_ncopy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) struct NcopyM {
|
||||
items: Vec<(Common, EntityKind)>,
|
||||
selecting_done: bool,
|
||||
/// Block entities visited by every selection so far: one allowance for the whole command.
|
||||
visits: usize,
|
||||
base: Option<Vec2>,
|
||||
}
|
||||
|
||||
@@ -116,7 +97,7 @@ impl Interactive for NcopyM {
|
||||
return match i {
|
||||
Input::Point(p) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let leaf = nearest_leaf(leaves_of(s, h, &mut self.visits)?, p).ok_or_else(|| other("Nothing nested there."))?;
|
||||
let leaf = nearest_leaf(leaves_of(s, h)?, p).ok_or_else(|| other("Nothing nested there."))?;
|
||||
s.echo(format!("Nested object: {}", leaf.1.type_name()));
|
||||
if self.items.len() < MAX_GEN {
|
||||
self.items.push(leaf);
|
||||
|
||||
@@ -777,25 +777,3 @@ fn offset_elliptical_arc_follows_picked_side() {
|
||||
let outer = max_x(json!([20, 20]));
|
||||
assert!((outer - 11.0).abs() < 0.05, "outer side went inward: max x = {outer}");
|
||||
}
|
||||
|
||||
/// Nested references multiply visits even when no leaf results; NCOPY refuses past its visit budget.
|
||||
#[test]
|
||||
fn ncopy_charges_every_nested_visit() {
|
||||
use cadcraft_doc::{Block, Common, Entity, Insert};
|
||||
let mut s = Session::new();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut n = 1;
|
||||
for level in 0..15 {
|
||||
let mut b = Block::new(&format!("B{level}"));
|
||||
for _ in 0..100 {
|
||||
let ins = Insert { block: format!("B{}", level + 1), ..Insert::default() };
|
||||
b.entities.push(Entity::new(Handle(n), EntityKind::Insert(ins)));
|
||||
n += 1;
|
||||
}
|
||||
d.blocks.insert(b.name.clone(), std::sync::Arc::new(b));
|
||||
}
|
||||
d.blocks.insert("B15".into(), std::sync::Arc::new(Block::new("B15")));
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(Insert { block: "B0".into(), ..Insert::default() })).unwrap();
|
||||
let err = s.execute("ncopy", &json!({ "handle": h.hex() })).unwrap_err().to_string();
|
||||
assert!(err.contains("nests too many objects"), "{err}");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -697,7 +697,7 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(crate) use drawing::from_json as drawing_from_json;
|
||||
pub(crate) use drawing::{load_source, support_file};
|
||||
pub(crate) use drawing::load_source;
|
||||
|
||||
impl Session {
|
||||
/// A file APPLOAD loaded, by its name (with or without `.lsp`, any case).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,8 +235,7 @@ pub(super) fn autoload(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispErr
|
||||
return Err(LispError::bad_arg("command name", &cmd));
|
||||
}
|
||||
// The stub forgets itself, loads the file and calls what it defined.
|
||||
// A file in the support folder is loaded from there, never from a same-named file of the working folder.
|
||||
let file_lit = to_text(&Value::Str(crate::lisp::support_file(ctx.s, &file).map_or_else(|| file.clone(), |p| p.into())), true);
|
||||
let file_lit = to_text(&Value::Str(file.clone()), true);
|
||||
let src = format!(
|
||||
"(lambda () (setq C:{name} nil) (load {file_lit}) (if C:{name} (C:{name}) (princ \"\\n; error: {} did not define {name}\")) (princ))",
|
||||
file.replace(['"', '\\'], "/")
|
||||
|
||||
@@ -1128,9 +1128,7 @@ pub fn trans(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
/// What `(load name)` reads: a file loaded through APPLOAD (the web), or one on disk, with
|
||||
/// `.lsp` added when the name has no extension.
|
||||
pub fn load_source(s: &Session, name: &str) -> Option<String> {
|
||||
// An absolute path names a file, not an APPLOAD entry that happens to share its base name.
|
||||
let absolute = std::path::Path::new(name).is_absolute();
|
||||
if !absolute && let Some(t) = s.lisp_file(name) {
|
||||
if let Some(t) = s.lisp_file(name) {
|
||||
return Some(t);
|
||||
}
|
||||
let candidates =
|
||||
@@ -1141,37 +1139,10 @@ pub fn load_source(s: &Session, name: &str) -> Option<String> {
|
||||
candidates.iter().chain(&support).find_map(super::builtins::read_text_file)
|
||||
}
|
||||
|
||||
/// The file `name` names in the support folder, when it is there: what an autoload registration
|
||||
/// loads, so a same-named file in the working folder cannot take its place. `None` for an absolute
|
||||
/// path, no support folder or no such file there (the general search then applies, as before).
|
||||
pub fn support_file(s: &Session, name: &str) -> Option<String> {
|
||||
if std::path::Path::new(name).is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let dir = s.lisp_support_dir()?;
|
||||
let candidates =
|
||||
if std::path::Path::new(name).extension().is_none() { vec![format!("{name}.lsp"), name.to_string()] } else { vec![name.to_string()] };
|
||||
candidates.iter().map(|c| dir.join(c)).find(|p| p.is_file()).map(|p| std::path::absolute(&p).unwrap_or(p).to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn autoload_prefers_the_support_folder_file() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-lisp-support-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("lazy.lsp"), "").unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(support_file(&s, "lazy"), None);
|
||||
s.set_lisp_support_dir(Some(dir.clone()));
|
||||
assert_eq!(support_file(&s, "lazy"), Some(dir.join("lazy.lsp").to_string_lossy().into_owned()));
|
||||
assert_eq!(support_file(&s, "other"), None);
|
||||
assert_eq!(support_file(&s, dir.join("lazy.lsp").to_str().unwrap()), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// One 64 KB string occurring many times is charged by its length each time.
|
||||
#[test]
|
||||
fn shared_strings_spend_the_conversion_budgets() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user