mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
* psd: harden the standalone binary parsers (gradients, swatches, patterns, filter effects, metadata, resources, tagged blocks) Correctness and robustness work on the psd crate's auxiliary parsers, with fuzz targets and property tests so the invariants are enforced, not just asserted. grd (gradients): - Split num()/finite() into a single guard with a saturating f64->f32 cast: only NaN has no value, everything else (including +/-inf and 1e300) saturates to the representable range so the mapping stays monotonic instead of snapping to a field default and inverting a hostile value. - Hue now wraps with rem_euclid(360) instead of clamping (370 -> 10, not 370 -> 360, which would be a different colour at the boundary). - Every colour model is bounded: RGB/CMYK/Gray to 0..=1, L* to 0..=100 and a*/b* to the CIELAB gamut (-128..=127), so a downstream converter never sees out-of-gamut input. aco/ase (swatches): - Share one io::utf16_units() helper that caps a name by UTF-16 code units without ever splitting a surrogate pair; keeping it in one place stops the fix from drifting between the two formats. Coverage: - New fuzz targets for grd, aco, ase, patterns, filter_effects, metadata, tagged and resources (wired into the fuzz CI matrix). - Property tests mirroring each fuzz invariant (round-trip stability, no panic on arbitrary bytes, documented colour/number ranges) so normal CI covers them; the fuzz jobs run on demand. 369 psd tests pass; clippy -D warnings and rustfmt clean; layering and wasm check pass; all psd fuzz targets build and run clean. * psd: address Gemini/Claude review of the parser hardening Follow-up to the previous commit, applying the substantive points from two independent code reviews (Gemini 3.8: ship; Claude: ship after small fixes). Production: - grd hue: an in-range value (0..=360, including exactly 360) is now kept verbatim; only an out-of-range hue wraps with rem_euclid. Wrapping unconditionally altered a legal 360 to 0 (and f32 round-off on a tiny negative could itself land on 360), changing the colour at the boundary for anything that interpolates in HSB. - ase: compile-time assert that MAX_NAME_UNITS < u16::MAX, since put_name writes the length as `units.len() as u16 + 1`; guards the invariant if the cap is ever raised. Stronger test/fuzz invariants (byte stability alone would pass even if the writer silently dropped a field; these assert semantic equality): - metadata fuzz: assert the parsed-then-written block re-parses to the same items (the weakened byte-only check was the fix the first fuzz run surfaced). - patterns fuzz: assert parse(write(parse(x))) == parse(x); channel offsets/padding are wire artifacts, not fields, so the round trip is lossless (confirmed, 2M execs). - filter_effects fuzz: assert back == fx as well as byte stability; also decode every cached slot, not just the mask. - resources fuzz: round-trip ResolutionInfo through to_bytes/from_bytes. Coverage: - tagged fuzz: select the block key from a table of every typed/strict key via the first byte, so the fuzzer reaches each branch instead of guessing a 4-byte key. - new property tests: damage-a-valid-block for metadata/patterns/filter_effects (pure random bytes rarely pass a header), and check_structure over every structural key. - grd_every_colour_model_is_clamped_and_finite now pins exact saturation values (a*/b* to +/-128, L* to 0/100, units to 0/1), so a weaker clamp is caught, not just an out-of-range value; grd_hue_wraps now pins the exact-360 and 0 boundaries. Verified: 371 psd tests pass; clippy -D warnings, rustfmt, layers and wasm clean; all 13 fuzz targets build and run clean (metadata 11.5M, patterns 2M, filter_effects 5.9M, resources 3.5M, tagged 5.4M execs); the a*/b* clamp-weakening and hue-guard-removal mutants are both killed by the strengthened tests. --------- Co-authored-by: photocraft-agent UNCONFIGURED <unconfigured@invalid.local>
294 lines
12 KiB
YAML
294 lines
12 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
workflow_dispatch:
|
|
inputs:
|
|
fuzz:
|
|
description: "Run the fuzz job"
|
|
type: boolean
|
|
default: false
|
|
fuzz_seconds:
|
|
description: "Seconds per fuzz target"
|
|
default: "600"
|
|
|
|
env:
|
|
CARGO_TERM_COLOR: always
|
|
RUST_BACKTRACE: 1
|
|
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
|
|
# debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough
|
|
# for the Linux job's second, craft-fonts build (it ran out of disk with full debug info).
|
|
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
|
|
|
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge
|
|
# that lands while main's run is in progress waits for it, so every main state that gets a run is
|
|
# checked to the end. (Cancelling here left main unverified whenever merges came close together.)
|
|
concurrency:
|
|
group: ci-${{ github.ref }}
|
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
|
|
jobs:
|
|
# Which OSes the test job runs on. main, manual runs and PRs labelled `full-ci` get Linux and
|
|
# Windows. Other PRs get Linux, plus Windows when they touch code whose behaviour differs by
|
|
# platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile.
|
|
# macOS is tested only by release.yml.
|
|
platforms:
|
|
name: select platforms
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
os: ${{ steps.pick.outputs.os }}
|
|
web: ${{ steps.pick.outputs.web }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
- id: pick
|
|
env:
|
|
EVENT: ${{ github.event_name }}
|
|
BASE: ${{ github.event.pull_request.base.sha }}
|
|
FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }}
|
|
run: |
|
|
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
|
|
# release.yml builds and tests macOS (a failing release is fixed and rebuilt).
|
|
all='["ubuntu-latest","windows-latest"]'
|
|
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
|
|
echo "os=$all" >> "$GITHUB_OUTPUT"
|
|
echo "web=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
# The browser build (a fat-LTO wasm build) runs only when something it compiles or
|
|
# packages changed: the web app and its packaging, crate sources and manifests (the web
|
|
# build compiles nearly every crate), the workspace manifests and this workflow.
|
|
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/photocraft-web/|packaging/web/|packaging/env\.sh$|crates/[^/]+/(src/|Cargo\.toml$|build\.rs$)|Cargo\.(toml|lock)$|rust-toolchain(\.toml)?$|\.github/workflows/ci\.yml$)'; then
|
|
echo "web=true" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo "web=false" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
|
|
echo "os=$all" >> "$GITHUB_OUTPUT"
|
|
else
|
|
echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
test:
|
|
name: test (${{ matrix.os }})
|
|
needs: platforms
|
|
runs-on: ${{ matrix.os }}
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: ${{ fromJSON(needs.platforms.outputs.os) }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
# Unused toolchains in the image (Android SDK, .NET, GHC, CodeQL) are deleted in the
|
|
# background: the space is free before the craft-fonts build needs it, and nothing waits.
|
|
- name: Install Linux system dependencies (egui/winit/rfd)
|
|
if: runner.os == 'Linux'
|
|
run: |
|
|
nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \
|
|
/opt/hostedtoolcache/CodeQL </dev/null >/dev/null 2>&1 &
|
|
sudo apt-get update
|
|
sudo apt-get install -y --no-install-recommends \
|
|
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
|
libgl1-mesa-dev libgtk-3-dev
|
|
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
components: clippy, rustfmt
|
|
|
|
# Only main saves caches; PRs restore main's. Every PR used to save its own set (up to
|
|
# 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's.
|
|
# A failing test still saves: the cache holds compiled dependencies, not test results.
|
|
- uses: Swatinem/rust-cache@v2
|
|
with:
|
|
save-if: ${{ github.ref == 'refs/heads/main' }}
|
|
cache-on-failure: true
|
|
|
|
- name: Format
|
|
if: runner.os == 'Linux'
|
|
run: cargo fmt --all -- --check
|
|
|
|
# `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate.
|
|
- name: Test
|
|
run: cargo test --workspace --locked --features heif
|
|
|
|
# The same codecs without the optional decoder: HEIC must stay a clear "not in this build"
|
|
# error for distributors who leave it out.
|
|
- name: Test without heif
|
|
if: runner.os == 'Linux'
|
|
run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked
|
|
|
|
# The same tests built with the optional craft-fonts input (crates/text/build.rs), so the
|
|
# Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace
|
|
# --features heif` of the Test step: any other package or feature selection resolves a
|
|
# different feature set, recompiles the whole dependency graph into the same target dir and
|
|
# runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800).
|
|
- name: Check out craft-fonts
|
|
if: runner.os == 'Linux'
|
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
with:
|
|
repository: storytold/craft-fonts
|
|
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
|
path: craft-fonts
|
|
|
|
- name: Test with craft-fonts
|
|
if: runner.os == 'Linux'
|
|
env:
|
|
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
|
CRAFT_FONTS_REQUIRED: "1"
|
|
run: cargo test --workspace --locked --features heif
|
|
|
|
- name: Clippy
|
|
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
|
|
|
|
- name: Dependency layering
|
|
run: cargo xtask layers
|
|
|
|
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
|
# prefs audit). Static: it reads committed files only, no benchmarks run here.
|
|
- name: Scorecard up to date
|
|
if: runner.os == 'Linux'
|
|
run: cargo xtask scorecard --check
|
|
|
|
corpus:
|
|
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
|
|
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
|
|
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
|
|
# pin or manifest does, so normally this restores in seconds (fork PRs read the base cache).
|
|
name: corpus tests
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
# test-corpus builds with --release, but here without the release profile's thin LTO and
|
|
# single codegen unit: they ran LTO over the dependency graph for each of the ~95 corpus test
|
|
# binaries, so building took 23.5 of the job's 37 minutes while the tests ran for 1.5.
|
|
# Turning them off builds 2.7x faster; the code under test is the same.
|
|
CARGO_PROFILE_RELEASE_LTO: "false"
|
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "16"
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
- uses: Swatinem/rust-cache@v2
|
|
with:
|
|
key: corpus
|
|
save-if: ${{ github.ref == 'refs/heads/main' }}
|
|
cache-on-failure: true
|
|
- name: Restore test corpora
|
|
uses: actions/cache@v4
|
|
with:
|
|
path: corpus/
|
|
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
|
|
- name: Fetch and verify corpora
|
|
run: cargo xtask corpus --all
|
|
- name: Corpus tests
|
|
run: cargo xtask test-corpus
|
|
|
|
wasm:
|
|
name: wasm32 check
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
targets: wasm32-unknown-unknown
|
|
- uses: Swatinem/rust-cache@v2
|
|
with:
|
|
key: wasm32
|
|
save-if: ${{ github.ref == 'refs/heads/main' }}
|
|
cache-on-failure: true
|
|
- name: Check wasm-safe crates
|
|
run: cargo xtask wasm
|
|
|
|
web:
|
|
name: web build and wasm size gate
|
|
# PRs run it only when web-relevant files change (see `platforms`); main always does.
|
|
needs: platforms
|
|
if: needs.platforms.outputs.web == 'true'
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
# Match release.yml so PR results reflect what would actually ship.
|
|
TRUNK_VERSION: 0.21.14
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
targets: wasm32-unknown-unknown
|
|
- uses: Swatinem/rust-cache@v2
|
|
with:
|
|
key: ci-web
|
|
save-if: ${{ github.ref == 'refs/heads/main' }}
|
|
cache-on-failure: true
|
|
- 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
|
|
trunk --version
|
|
# package.sh runs Trunk's release build, validates relative URLs, checks the 24 MiB
|
|
# wasm limit and exercises the same packaging step used by release.yml.
|
|
- name: Build and verify browser release
|
|
run: packaging/web/package.sh
|
|
- name: Report wasm size
|
|
if: success()
|
|
run: |
|
|
{
|
|
echo "### Browser WebAssembly size"
|
|
echo
|
|
echo "| File | Bytes | MiB | Limit |"
|
|
echo "| --- | ---: | ---: | ---: |"
|
|
for wasm in dist/web/*.wasm; do
|
|
[ -f "$wasm" ] || continue
|
|
bytes=$(wc -c < "$wasm" | tr -d ' ')
|
|
mib=$(awk -v n="$bytes" 'BEGIN { printf "%.2f", n / 1048576 }')
|
|
echo "| $(basename "$wasm") | $bytes | $mib | 24 MiB |"
|
|
done
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
fuzz:
|
|
name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }})
|
|
# Placeholder: manual trigger only until a nightly schedule is enabled.
|
|
if: github.event_name == 'workflow_dispatch' && inputs.fuzz
|
|
runs-on: ubuntu-latest
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
target:
|
|
- { crate: psd, name: parse }
|
|
- { crate: psd, name: descriptor }
|
|
- { crate: psd, name: image_source_data }
|
|
- { crate: psd, name: grd }
|
|
- { crate: psd, name: ase }
|
|
- { crate: psd, name: aco }
|
|
- { crate: psd, name: patterns }
|
|
- { crate: psd, name: filter_effects }
|
|
- { crate: psd, name: metadata }
|
|
- { crate: psd, name: tagged }
|
|
- { crate: psd, name: resources }
|
|
- { crate: codecs, name: decode_any }
|
|
- { crate: codecs, name: decode_png }
|
|
- { crate: codecs, name: decode_jpeg }
|
|
- { crate: codecs, name: decode_tiff }
|
|
- { crate: codecs, name: decode_exr }
|
|
- { crate: codecs, name: decode_heif }
|
|
- { crate: affinity, name: preview }
|
|
- { crate: affinity, name: import_preview, args: "--features io" }
|
|
- { crate: format, name: load }
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: dtolnay/rust-toolchain@nightly
|
|
- uses: Swatinem/rust-cache@v2
|
|
with:
|
|
workspaces: crates/${{ matrix.target.crate }}/fuzz
|
|
- name: Install cargo-fuzz
|
|
run: cargo install cargo-fuzz --locked
|
|
- name: Fuzz
|
|
working-directory: crates/${{ matrix.target.crate }}
|
|
run: cargo fuzz run ${{ matrix.target.name }} ${{ matrix.target.args }} -- -max_total_time=${{ inputs.fuzz_seconds }}
|
|
- name: Upload crash artifacts
|
|
if: failure()
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: fuzz-${{ matrix.target.crate }}-${{ matrix.target.name }}
|
|
path: crates/${{ matrix.target.crate }}/fuzz/artifacts
|