mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-13 23:17:42 +00:00
Harden temporary files and release inputs (#153)
Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10 files changed
+336
-21
No files matched your search
@@ -28,6 +28,7 @@ jobs:
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# The Japanese font tests run against craft-fonts (an optional build input; they skip without it).
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -45,7 +46,11 @@ jobs:
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
curl -fsSL -o /tmp/rustup-init \
|
||||
https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init
|
||||
test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956"
|
||||
chmod 700 /tmp/rustup-init
|
||||
/tmp/rustup-init -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
|
||||
@@ -22,14 +22,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
archive="$RUNNER_TEMP/actionlint.tar.gz"
|
||||
curl -fsSL -o "$archive" \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum -c -
|
||||
tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
|
||||
@@ -73,6 +73,7 @@ jobs:
|
||||
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -131,6 +132,7 @@ jobs:
|
||||
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -181,14 +183,22 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
- arch: x86_64
|
||||
runner: ubuntu-22.04
|
||||
deb: amd64
|
||||
nfpm_sha256: 3f1cf344bd0b57373ca55636a78c08b0491f7293d609a456a9ac3b0b150fda97
|
||||
- arch: aarch64
|
||||
runner: ubuntu-22.04-arm
|
||||
deb: arm64
|
||||
nfpm_sha256: 27419eb382695a7942be8ad52259f3ec1854fad001b3ae4baed34ce39a223b97
|
||||
env:
|
||||
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
NFPM_SHA256: ${{ matrix.nfpm_sha256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -203,6 +213,7 @@ jobs:
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
printf '%s %s\n' "$NFPM_SHA256" "$RUNNER_TEMP/nfpm.deb" | sha256sum -c -
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
@@ -286,6 +297,7 @@ jobs:
|
||||
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -305,7 +317,11 @@ jobs:
|
||||
# The FreeBSD CI packages, plus bash for the packaging script.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
curl -fsSL -o /tmp/rustup-init \
|
||||
https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-freebsd/rustup-init
|
||||
test "$(sha256 -q /tmp/rustup-init)" = "4d9fef2e40731489f3186c61a0f178d54d79864fe3d34791edf5b17f70074956"
|
||||
chmod 700 /tmp/rustup-init
|
||||
/tmp/rustup-init -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
@@ -334,8 +350,10 @@ jobs:
|
||||
DESIGNCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
DESIGNCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
TRUNK_SHA256: f2b4680cd239693a646a2795e4633c625328d7b2a044fbe749fa3a2fe9e7036b
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Optional font input (docs: storytold/craft-fonts docs/integration.md); releases always embed it.
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -350,8 +368,11 @@ jobs:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
archive="$RUNNER_TEMP/trunk.tar.gz"
|
||||
curl -fsSL -o "$archive" \
|
||||
"https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz"
|
||||
printf '%s %s\n' "$TRUNK_SHA256" "$archive" | sha256sum -c -
|
||||
tar -xzf "$archive" -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
|
||||
Generated
+1
@@ -958,6 +958,7 @@ dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"wasm-bindgen-test",
|
||||
"zip",
|
||||
|
||||
@@ -43,6 +43,7 @@ unicode-script = "0.5"
|
||||
unicode-bidi-mirroring = "0.4"
|
||||
unicode-segmentation = "1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
tempfile = "3"
|
||||
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
|
||||
@@ -29,6 +29,9 @@ log = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
designcraft-fonts = { workspace = true, features = ["testing"] }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! File › Print: the pages go to a printer through the system print spooler (`lpr`), as PDF.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -55,7 +58,8 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let printer = str_param(p, "printer").map(str::to_string);
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let pdf = designcraft_pdf::export_pdf(d, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let file = std::env::temp_dir().join(format!("designcraft-print-{}-{}.pdf", std::process::id(), d.title.replace(['/', '\\'], "_")));
|
||||
let mut file = secure_print_file()?;
|
||||
let file_path = file.path().to_path_buf();
|
||||
let mut cmd: Vec<String> = vec!["lpr".into()];
|
||||
if let Some(pr) = &printer {
|
||||
cmd.extend(["-P".into(), pr.clone()]);
|
||||
@@ -63,13 +67,14 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if copies > 1 {
|
||||
cmd.extend(["-#".into(), copies.to_string()]);
|
||||
}
|
||||
cmd.extend(["-T".into(), d.title.clone(), file.to_string_lossy().to_string()]);
|
||||
cmd.extend(["-T".into(), d.title.clone(), file_path.to_string_lossy().to_string()]);
|
||||
let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len());
|
||||
let out = json!({"printer": printer, "copies": copies, "pages": pages, "command": cmd, "bytes": pdf.len()});
|
||||
if dry {
|
||||
return Ok(out);
|
||||
}
|
||||
std::fs::write(&file, &pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.display())))?;
|
||||
file.as_file_mut().write_all(&pdf).map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
file.as_file_mut().flush().map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
let st = std::process::Command::new(&cmd[0]).args(&cmd[1..]).output().map_err(|e| bad("file.print", format!("lpr: {e}")))?;
|
||||
if !st.status.success() {
|
||||
return Err(bad("file.print", String::from_utf8_lossy(&st.stderr).trim().to_string()));
|
||||
@@ -78,6 +83,23 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn secure_print_file() -> Result<tempfile::NamedTempFile> {
|
||||
let file = tempfile::Builder::new()
|
||||
.prefix("designcraft-print-")
|
||||
.suffix(".pdf")
|
||||
.tempfile()
|
||||
.map_err(|e| EngineError::Other(format!("temporary print file: {e}")))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.as_file()
|
||||
.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", file.path().display())))?;
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
@@ -93,8 +115,34 @@ mod tests {
|
||||
let cmd: Vec<&str> = r["command"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
|
||||
assert_eq!(&cmd[..5], ["lpr", "-P", "Office", "-#", "2"]);
|
||||
assert!(cmd.last().unwrap().ends_with(".pdf"));
|
||||
assert!(!std::path::Path::new(cmd.last().unwrap()).exists());
|
||||
assert!(r["bytes"].as_u64().unwrap() > 500);
|
||||
assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err());
|
||||
assert!(s.execute("file.printers", &json!({})).unwrap()["printers"].is_array());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn print_uses_distinct_ephemeral_files() {
|
||||
let first = super::secure_print_file().unwrap();
|
||||
let second = super::secure_print_file().unwrap();
|
||||
let first_path = first.path().to_path_buf();
|
||||
let second_path = second.path().to_path_buf();
|
||||
assert_ne!(first_path, second_path);
|
||||
drop((first, second));
|
||||
assert!(!first_path.exists());
|
||||
assert!(!second_path.exists());
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), unix))]
|
||||
#[test]
|
||||
fn print_file_is_private_and_removed_on_drop() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let file = super::secure_print_file().unwrap();
|
||||
let path = file.path().to_path_buf();
|
||||
assert_eq!(file.as_file().metadata().unwrap().permissions().mode() & 0o777, 0o600);
|
||||
drop(file);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,14 @@
|
||||
//!
|
||||
//! A recovery entry is `<uid>.designcraft` (the document) plus `<uid>.json` (`{"path", "title",
|
||||
//! "saved"}`: the original file, the title, and when it was written).
|
||||
//!
|
||||
//! Each file is replaced atomically, but the pair is not a transaction. The document is published
|
||||
//! and made durable before its metadata so a crash can leave metadata missing or stale, never a
|
||||
//! listed metadata record without a complete primary document. Listing and opening recovery data
|
||||
//! therefore treat metadata as optional.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -30,6 +37,10 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
|
||||
|
||||
/// Remove a document's recovery entry (it was saved or closed).
|
||||
pub fn discard(dir: &Path, uid: u64) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if validate_recovery_dir(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let (d, m) = files(dir, uid);
|
||||
let _ = std::fs::remove_file(d);
|
||||
let _ = std::fs::remove_file(m);
|
||||
@@ -38,7 +49,7 @@ pub fn discard(dir: &Path, uid: u64) {
|
||||
/// Write recovery entries for the dirty documents (and drop those of clean ones). Returns how
|
||||
/// many were written.
|
||||
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
secure_recovery_dir(dir)?;
|
||||
let mut n = 0;
|
||||
for d in &s.docs {
|
||||
if !d.is_dirty() {
|
||||
@@ -48,18 +59,135 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> {
|
||||
let (doc, meta) = files(dir, d.uid);
|
||||
let bytes = crate::cmd::to_bytes(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref()));
|
||||
// Write then rename, so a crash mid-write never leaves a torn file.
|
||||
let tmp = doc.with_extension("tmp");
|
||||
std::fs::write(&tmp, &bytes).and_then(|_| std::fs::rename(&tmp, &doc)).map_err(|e| EngineError::Other(format!("{}: {e}", doc.display())))?;
|
||||
write_recovery_file(&doc, &bytes)?;
|
||||
let m = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()});
|
||||
std::fs::write(&meta, m.to_string()).map_err(|e| EngineError::Other(format!("{}: {e}", meta.display())))?;
|
||||
write_recovery_file(&meta, m.to_string().as_bytes())?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn secure_recovery_dir(dir: &Path) -> Result<()> {
|
||||
match std::fs::symlink_metadata(dir) {
|
||||
Ok(_) => return validate_recovery_dir(dir),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
}
|
||||
|
||||
// Set the mode while creating directories so existing caller-owned directories are never
|
||||
// chmodded. DirBuilder applies this mode only to components it creates; the process umask may
|
||||
// make it more restrictive.
|
||||
#[cfg(unix)]
|
||||
let create_result = {
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true).mode(0o700).create(dir)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let create_result = std::fs::create_dir_all(dir);
|
||||
|
||||
create_result.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
|
||||
// Re-check after creation. This also fails closed if another process won a creation race with
|
||||
// a symlink or, on Windows, any reparse-point leaf.
|
||||
validate_recovery_dir(dir)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn secure_recovery_dir(_dir: &Path) -> Result<()> {
|
||||
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let Some(dir) = path.parent() else {
|
||||
return Err(EngineError::Other(format!("{} has no parent folder", path.display())));
|
||||
};
|
||||
validate_recovery_dir(dir)?;
|
||||
let mut tmp = tempfile::Builder::new()
|
||||
.prefix(".designcraft-recovery-")
|
||||
.suffix(".tmp")
|
||||
.tempfile_in(dir)
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
tmp.as_file().set_permissions(std::fs::Permissions::from_mode(0o600)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
}
|
||||
tmp.as_file_mut().write_all(bytes).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
tmp.as_file_mut().sync_all().map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
// Validate the non-followed leaf again immediately before renaming the randomized temporary
|
||||
// file to its fixed sibling destination.
|
||||
validate_recovery_dir(dir)?;
|
||||
tmp.persist(path).map_err(|e| EngineError::Other(format!("{}: {}", path.display(), e.error)))?;
|
||||
sync_recovery_dir(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn validate_recovery_dir(dir: &Path) -> Result<()> {
|
||||
let metadata = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
if is_link_like(&metadata) {
|
||||
return Err(EngineError::Other(format!("{} must not be a symbolic link or reparse point", dir.display())));
|
||||
}
|
||||
if !metadata.file_type().is_dir() {
|
||||
return Err(EngineError::Other(format!("{} is not a directory", dir.display())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), windows))]
|
||||
fn is_link_like(metadata: &std::fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
|
||||
// This catches all reparse-point leaves, including junctions, rather than relying solely on
|
||||
// FileType::is_symlink().
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(windows)))]
|
||||
fn is_link_like(metadata: &std::fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn sync_recovery_dir(dir: &Path) -> Result<()> {
|
||||
validate_recovery_dir(dir)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let directory = std::fs::File::open(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
directory.sync_all().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Rust does not expose a portable durable-directory handle. Try where the platform
|
||||
// supports opening directories, but do not turn a fully persisted file into a reported
|
||||
// failure where it does not.
|
||||
if let Ok(directory) = std::fs::File::open(dir) {
|
||||
let _ = directory.sync_all();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> {
|
||||
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
||||
}
|
||||
|
||||
/// Recovery entries in `dir`: (uid, metadata).
|
||||
pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
let mut out = Vec::new();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if validate_recovery_dir(dir).is_err() {
|
||||
return out;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
@@ -82,8 +210,12 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
/// Reopen every recovery entry as an unsaved document (and remove the entries: the new session
|
||||
/// writes its own). Returns the opened documents' indices.
|
||||
pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
validate_recovery_dir(dir)?;
|
||||
let mut opened = Vec::new();
|
||||
for (uid, meta) in list(dir) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
validate_recovery_dir(dir)?;
|
||||
let (doc, _) = files(dir, uid);
|
||||
let Ok(bytes) = std::fs::read(&doc) else { continue };
|
||||
let mut d = match crate::cmd::from_bytes(&bytes) {
|
||||
@@ -108,6 +240,13 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dirty_session() -> Session {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
session.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap();
|
||||
session
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_unsaved_documents() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-recovery-{}", std::process::id()));
|
||||
@@ -135,6 +274,82 @@ mod tests {
|
||||
assert!(list(&dir).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn recovery_data_is_private_to_the_user() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().join("recovery");
|
||||
let s = dirty_session();
|
||||
assert_eq!(save(&s, &dir).unwrap(), 1);
|
||||
|
||||
assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
|
||||
assert_eq!(entries.len(), 2);
|
||||
for entry in entries {
|
||||
let entry = entry.unwrap();
|
||||
assert_eq!(entry.metadata().unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_recovery_directory_is_used() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().join("recovery");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
|
||||
assert_eq!(save(&dirty_session(), &dir).unwrap(), 1);
|
||||
assert_eq!(list(&dir).len(), 1);
|
||||
assert!(std::fs::symlink_metadata(&dir).unwrap().file_type().is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_directory_recovery_path_is_rejected() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("recovery");
|
||||
std::fs::write(&path, b"not a directory").unwrap();
|
||||
|
||||
assert!(save(&dirty_session(), &path).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"not a directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn existing_recovery_directory_permissions_are_preserved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().join("recovery");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o750)).unwrap();
|
||||
|
||||
assert_eq!(save(&dirty_session(), &dir).unwrap(), 1);
|
||||
assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o750);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_recovery_directory_is_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let destination = root.path().join("destination");
|
||||
let link = root.path().join("recovery");
|
||||
std::fs::create_dir(&destination).unwrap();
|
||||
symlink(&destination, &link).unwrap();
|
||||
|
||||
assert!(save(&dirty_session(), &link).is_err());
|
||||
assert!(std::fs::read_dir(&destination).unwrap().next().is_none());
|
||||
std::fs::write(destination.join("7.designcraft"), b"unchanged").unwrap();
|
||||
std::fs::write(destination.join("7.json"), b"{}").unwrap();
|
||||
assert!(list(&link).is_empty());
|
||||
discard(&link, 7);
|
||||
assert_eq!(std::fs::read(destination.join("7.designcraft")).unwrap(), b"unchanged");
|
||||
assert!(open(&mut Session::new(), &link).is_err());
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -94,6 +94,11 @@ fi
|
||||
|
||||
# ---- AppImage -----------------------------------------------------------------------------------
|
||||
if has appimage; then
|
||||
APPIMAGETOOL_VERSION=1.9.1
|
||||
case "$ARCH" in
|
||||
x86_64) APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 ;;
|
||||
aarch64) APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 ;;
|
||||
esac
|
||||
APPDIR="$WORK/DesignCraft.AppDir"
|
||||
cp -R "$STAGE" "$APPDIR"
|
||||
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
|
||||
@@ -104,11 +109,16 @@ if has appimage; then
|
||||
|
||||
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
|
||||
if [ -z "$TOOL" ]; then
|
||||
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
|
||||
TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage"
|
||||
if [ ! -x "$TOOL" ]; then
|
||||
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
chmod +x "$TOOL"
|
||||
download="$TOOL.download"
|
||||
curl -fsSL -o "$download" \
|
||||
"https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage"
|
||||
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c -
|
||||
chmod 755 "$download"
|
||||
mv "$download" "$TOOL"
|
||||
fi
|
||||
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c -
|
||||
fi
|
||||
# Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be
|
||||
# relative, e.g. target/agent-<name>).
|
||||
|
||||
@@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) {
|
||||
|
||||
$script:SignTool = Find-SignTool
|
||||
$common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'DesignCraft', '/du', 'https://github.com/storytold/designcraft')
|
||||
$tmp = Join-Path ([IO.Path]::GetTempPath()) "designcraft-sign-$PID"
|
||||
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||
$tmp = Join-Path ([IO.Path]::GetTempPath()) "designcraft-sign-$([IO.Path]::GetRandomFileName())"
|
||||
New-Item -ItemType Directory -Path $tmp | Out-Null
|
||||
|
||||
try {
|
||||
if ($haveCert) {
|
||||
@@ -73,8 +73,15 @@ try {
|
||||
Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')"
|
||||
# The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID,
|
||||
# AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment.
|
||||
$trustedSigningVersion = '1.0.95'
|
||||
$trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f'
|
||||
$pkg = Join-Path $tmp 'trusted-signing.zip'
|
||||
Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg
|
||||
$uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg"
|
||||
Invoke-WebRequest -Uri $uri -OutFile $pkg
|
||||
$actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $trustedSigningSha256) {
|
||||
throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch"
|
||||
}
|
||||
Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force
|
||||
$dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll |
|
||||
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1
|
||||
|
||||
Reference in new issue
Block a user