From 74a81f9fb02c68fe0c54639435c92dff47c91965 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 01:46:19 -0600 Subject: [PATCH] Harden temporary files and release inputs (#153) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- .github/workflows/freebsd.yml | 7 +- .github/workflows/packaging-lint.yml | 8 +- .github/workflows/release.yml | 31 +++- Cargo.lock | 1 + Cargo.toml | 1 + crates/engine/Cargo.toml | 3 + crates/engine/src/cmd/print.rs | 54 ++++++- crates/engine/src/recovery.rs | 223 ++++++++++++++++++++++++++- packaging/linux/package.sh | 16 +- packaging/windows/sign.ps1 | 13 +- 10 files changed, 336 insertions(+), 21 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 77c0303..9b7503b 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -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. diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 59c0a1c..03fddb9 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 634552c..bbd8acf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 != '' diff --git a/Cargo.lock b/Cargo.lock index 513b343..2f57f01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,6 +958,7 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "wasm-bindgen-test", "zip", diff --git a/Cargo.toml b/Cargo.toml index af62549..34417be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 241fd28..23350e7 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index b944b62..724feb2 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -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 { 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 = 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 { 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 { } } +#[cfg(not(target_arch = "wasm32"))] +fn secure_print_file() -> Result { + 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()); + } } diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index e442f0a..a038c2d 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -5,7 +5,14 @@ //! //! A recovery entry is `.designcraft` (the document) plus `.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 { - 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 { 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> { + #[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> { 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)] diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 13411e7..c547509 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -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-). diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index dbfadbf..81efe52 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -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