Skip to content

feat: three-layer codec killbits (draft, do not merge) - #720

Draft
lilith wants to merge 6 commits into
mainfrom
feat/killbits-three-layer
Draft

feat: three-layer codec killbits (draft, do not merge)#720
lilith wants to merge 6 commits into
mainfrom
feat/killbits-three-layer

Conversation

@lilith

@lilith lilith commented Apr 20, 2026

Copy link
Copy Markdown
Member

Motivation

Three things to land in this PR:

  1. Stop leaking per-job security intent across jobs on the same Context.
    Today Build001/Execute001 inline security blocks mutate
    Context.security in place, so one job's narrowing persists and
    affects the next job on that Context. An earlier iteration of this
    PR made the mutation intersection-only when a trusted_policy is
    set, but the mutation itself was still there. This version fixes it
    properly: inline job security is scoped to the lifetime of one
    build/execute call and never writes through to Context state.

  2. Add a three-layer killbits system (format + codec) so trusted
    applications can pin the codec surface once and treat every
    downstream request as a strict narrower.
    Zero users of killbits
    today, so breaking in-flight shapes is free.

  3. Graceful codec substitution with observability. When a
    specific-codec EncoderPreset names a codec that's unavailable but
    another codec for the same wire format is live, the dispatcher
    substitutes the available codec and surfaces a machine-readable
    annotation on the response rather than failing the request.

The three layers

Layer Where Scope
1. Build-time ceiling imageflow_types::build_killbits::COMPILE_DENY_{DECODE,ENCODE} + feature_compiled_in Hard upper bound. Forks override at compile time.
2. Trusted policy v1/context/set_policyContext::trusted_policy Set once (or narrowed again) by the embedding application. May use allow-lists, deny-lists, or a per-format / per-codec table.
3. Job-level narrowing existing security field on Build001/Execute001, extended with formats and codecs Untrusted JSON may only deny. allow_* + table entries with decode: true / encode: true are rejected.

The effective grid for any operation is layer 1 ∩ layer 2 ∩ layer 3.
Enforcement fires at decoder instantiation (when the input buffer is
added) and at encoder dispatch (inside auto::create_encoder),
producing a structured JSON error:

{
  "error": "decode_not_available",
  "format": "jxl",
  "reasons": ["compile.feature_missing"],
  "net_support": { "formats": { ... }, "codecs": { ... } }
}

Per-job scoping (Part A — the pre-existing bug)

Context.security (mutable, process-wide) is replaced by three
Context fields that make the scoping explicit:

  • default_job_security: ExecutionSecurity — Context-scoped defaults,
    initialized from sane_defaults() or the trusted policy. Read-only
    during job execution.
  • trusted_policy: Option<Box<ExecutionSecurity>> — the layer-2
    baseline, set by v1/context/set_policy. Never mutated by job JSON.
  • active_job_security: Option<Box<ExecutionSecurity>> — the per-job
    effective value (trusted ∩ default ∩ inline), installed for the
    duration of a single build/execute and torn down on exit.

Context::effective_security(&self, inline) is the pure computation;
Context::current_security() is the single read point for per-node
limit checks. Inline security on Build001/Execute001 no longer
persists to Context.default_job_security.

max_json_bytes is the one field that stays Context-scoped — it
bounds the JSON parse itself, so it has to be known before the job's
own security block is visible.

Three regression tests pin this:

  • inline_max_decode_size_is_not_persisted_across_jobs
  • inline_killbits_is_not_persisted_across_jobs
  • trusted_policy_persists_but_inline_does_not

Codec-level killbits (Part B)

Format-level killbits grant/deny every codec for a given format.
Codec killbits add per-backend granularity: an operator can forbid
mozjpeg_encoder while keeping zen_jpeg_encoder, or disable a
specific decoder that's had a CVE without stubbing the format.

Types

In imageflow_types:

  • NamedEncoderName / NamedDecoderName#[non_exhaustive]
    snake_case serde enums mirroring every known NamedEncoders /
    NamedDecoders variant in core. Purely nominal; feature gating is
    handled at the core layer.
  • CodecKillbits { allow_encoders, deny_encoders, allow_decoders, deny_decoders } with the same validation (mutual exclusion,
    validate_job_level rejecting allow_*) as FormatKillbits.
  • ExecutionSecurity.codecs: Option<Box<CodecKillbits>>.

JSON examples

Allow-list form (trusted-policy only):

{
  "policy": {
    "codecs": {
      "allow_encoders": ["mozjpeg_encoder", "lodepng_encoder", "zen_webp_encoder"],
      "allow_decoders": ["mozjpeg_rs_decoder", "libpng_decoder", "gif_rs_decoder"]
    }
  }
}

Deny-list form (trusted-policy or job-level):

{
  "policy": {
    "codecs": {
      "deny_encoders": ["pngquant_encoder"],
      "deny_decoders": ["zen_jxl_decoder"]
    }
  }
}

Job-level narrowing:

{
  "framewise": { "steps": [ ... ] },
  "security": {
    "codecs": { "deny_encoders": ["mozjpeg_encoder"] }
  }
}

Net support grid (format × codec interaction)

v1/context/get_net_support response grows a codecs subtree and
per-format {decode,encode}_reasons vectors:

{
  "ok": true,
  "net_support": {
    "formats": {
      "jpeg": { "decode": true, "encode": true },
      "png":  { "decode": true, "encode": true },
      "avif": { "decode": true, "encode": false,
                "encode_reasons": ["no_available_encoder"] }
    },
    "codecs": {
      "mozjpeg_encoder":  { "available": true,  "format": "jpeg", "role": "encode" },
      "zen_jpeg_encoder": { "available": true,  "format": "jpeg", "role": "encode" },
      "zen_avif_encoder": { "available": false, "format": "avif", "role": "encode",
                            "reasons": ["format_denied"] },
      "libpng_encoder":   { "available": false, "format": "png",  "role": "encode",
                            "reasons": ["codec_killbits.deny_encoders"] }
    }
  },
  "trusted_policy_set": true,
  "compile_ceiling": {
    "denied_decode": [],
    "denied_encode": [],
    "features_missing": ["heic", "tiff", "pnm"]
  }
}

Graceful codec substitution (Part C — new in this revision)

Wire format vs implementation

The previous iteration of this PR treated each specific-codec
EncoderPreset variant as a contract to use that exact codec, and
rejected the request when the codec was denied. That's too strict for
the common operator case: "deny the C mozjpeg build because of a CVE,
but keep JPEG encoding working via zenjpeg." Callers that only care
about getting a valid JPEG back (the overwhelming majority) end up
with 422s they can't act on.

This revision splits the contract into two tiers:

  1. Wire format (the bytes the caller gets back). Still strictly
    honored: a request for JPEG always produces JPEG bytes, never PNG
    bytes. EncoderPreset::Format { format: avif } with AVIF denied
    still errors with encode_not_available — the format contract is
    sacred.
  2. Implementation (which specific codec ran). Now a soft
    preference. When the requested codec is unavailable but another
    codec for the same wire format is live, the dispatcher substitutes
    and annotates the response.

Substitution table

Legacy preset Substitute target (priority order) Notes
Mozjpeg { quality, progressive, matte } ZenJpegEncoderMozjpegRsEncoder quality scale identical (ApproxMozjpeg)
LibjpegTurbo { quality, progressive, optimize_huffman_coding, matte } ZenJpegEncoder (only) Excludes MozjpegRsEncoder — always optimizes Huffman, can't honor the disable toggle
Libpng { depth, matte, zlib_compression } ZenPngEncoderLodepngEncoder zlib_compression dropped on lode/zen path (annotated)
Lodepng { maximum_deflate } ZenPngEncoderLibpngEncoder maximum_deflate=true translated to zlib=9 on libpng
Pngquant { ... } (none — palette quantization is unique) Errors format_not_available
WebPLossy { quality } The other WebP encoder (libwebp ↔ zenwebp)
WebPLossless {} Same

Annotation shape

Each encoded image in the response carries an optional annotations
bag. Today's only channel is codec_substitution:

{
  "encodes": [{
    "io_id": 1,
    "preferred_extension": "jpg",
    "preferred_mime_type": "image/jpeg",
    "w": 800, "h": 600, "bytes": ...,
    "annotations": {
      "codec_substitution": {
        "requested": "mozjpeg_encoder",
        "actual": "zen_jpeg_encoder",
        "reason": "codec_killbits_deny_encoders",
        "field_translations": [
          "preset.quality → zen.quality",
          "preset.progressive → zen.progressive"
        ],
        "dropped_fields": []
      }
    }
  }]
}

reason values (serialized snake_case):

  • codec_killbits_deny_encoders — requested codec was in a job or
    trusted deny_encoders list.
  • codec_killbits_allow_encoders_excludes — requested codec wasn't
    in an allow_encoders list.
  • compile_feature_missing — the build didn't include the requested
    codec's feature gate.
  • compile_codec_const_denied — the format family is in
    COMPILE_DENY_ENCODE.
  • not_registered — the codec isn't in the runtime
    enabled_codecs registry.

The annotation is per-encode-step (attached to EncodeResult), not
per-job. A response with multiple encodes can have different
substitutions per step. EncodeAnnotations is forward-extensible —
additional annotation kinds can be added as sibling fields without
breaking callers that only understand codec_substitution.

Error shape when substitution is impossible

When the format itself has no live encoder (net.encode(format) = false) after all killbits + features are applied, the unified
format_not_available error fires at parse time:

{
  "error": "format_not_available",
  "format": "jpeg",
  "requested_codec": "mozjpeg_encoder",
  "reasons": ["all_jpeg_encoders_denied"],
  "net_support": { ... }
}

This also fires when the requested codec has no substitution
candidates — today just Pngquant (palette quantization is unique).

The pre-existing encode_not_available format-level error
(EncoderPreset::Format { format: jpeg } when JPEG is off) and
decode_not_available decoder-side error are unchanged.

Backward compatibility

Every existing workflow that uses a legacy EncoderPreset variant
(Mozjpeg, LibjpegTurbo, Libpng, Lodepng, WebPLossy,
WebPLossless) continues to work even when operators tighten codec
killbits — the dispatcher finds a substitute for the same wire format
and annotates the response. Clients that don't read annotations are
unaffected.

code:codec_not_available is still a valid error type on the schema
for the rare unsubstitutable case (e.g., Pngquant denied with no
quantizer substitute). Consumers that already match on it keep
working.

Decoder side

Decoder substitution follows the same pattern the encoder dispatch
landed in commit 4: create_decoder_for_magic_bytes walks all
matching decoders, skips any that are codec-killed, and only errors
when every decoder for the format is down. That logic pre-dates this
revision and already satisfies the substitution contract from the
decode side. Surfacing a structured per-decode annotation is tracked
as follow-up work — DecodeResult.annotations and
DecoderSubstitutionAnnotation are defined on the types side so the
schema is ready, but the core-side plumbing to populate them is
scoped to a follow-up PR.

Breaking behavior changes (would break hypothetical callers; today there are none)

  • Inline security on Build001/Execute001 no longer persists
    into Context.security.
    Callers that relied on "first job sets
    the limit, subsequent jobs inherit it" (not a documented contract,
    but possible in practice) must instead install their baseline via
    v1/context/set_policy.
  • Specific-codec EncoderPreset variants now substitute when the
    requested codec is denied and a same-format codec is available,
    instead of silently substituting on an outdated table. The request
    succeeds; the response carries a codec_substitution annotation.
    When no substitute exists (Pngquant denied, or every same-format
    codec denied), the request errors with format_not_available.
  • Context::configure_security is removed; its policy-set semantics
    live on as a private apply_trusted_policy reachable only through
    Context::set_trusted_policy.
  • Size sanity tests (test_context_size, test_thread_safe_context_size,
    test_calculate_context_heap_size) adjusted for the two new
    Option<Box<ExecutionSecurity>> fields, the new
    Option<Box<CodecKillbits>> on ExecutionSecurity, and the
    Option<Box<EncodeAnnotations>> on each CodecInstanceContainer.

Non-breaking changes

  • ExecutionSecurity is #[non_exhaustive], so the new codecs
    field is additive.
  • NamedEncoderName / NamedDecoderName are #[non_exhaustive].
  • EncodeAnnotations is forward-extensible (all fields optional).
  • SubstitutionReason is #[non_exhaustive].
  • Legacy jobs without security.formats or security.codecs behave
    identically to before.
  • ABI unchanged: imageflow_abi/src/lib.rs has no new exports and
    no signature changes. New endpoints are reached through the
    existing imageflow_context_send_json entry point.
  • No RIAPI changes. URL-layer plumbing is out of scope.

What this PR does NOT do

  • No imageflow_abi/src/lib.rs changes.
  • No imageflow_riapi changes.
  • No accept.* URL translation.
  • No renamed endpoints.
  • No ProcessConfig-style startup-mutable type.
  • No decoder-side annotation surfacing in DecodeResult
    (types are defined, core plumbing is a follow-up).

Test plan

  • cargo check --workspace is green at every commit.
  • cargo test -p imageflow_types --lib — 38 pass, 0 fail
    (added CodecKillbits-specific tests, SubstitutionReason
    serde tests, CodecSubstitutionAnnotation round-trip tests,
    EncodeAnnotations shape tests).
  • cargo test -p imageflow_core --lib — 78 pass, 0 fail.
  • cargo test -p imageflow_core --test integration killbits
    27 pass, 0 fail. Substitution coverage:
    deny_specific_encoder_substitutes_preset_when_lodepng_denied,
    lodepng_preset_errors_format_not_available_when_no_png_encoder_remains,
    pngquant_preset_errors_format_not_available_when_pngquant_denied,
    mozjpeg_preset_substitutes_when_mozjpeg_denied,
    libjpegturbo_preset_substitutes_mozjpeg_denied_to_zenjpeg_not_mozrs,
    libpng_preset_substitutes_when_libpng_denied,
    lodepng_preset_substitutes_when_lodepng_denied,
    webp_lossy_preset_substitutes_when_primary_webp_denied,
    webp_lossless_preset_substitutes_when_primary_webp_denied,
    format_preset_denied_stays_strict_no_substitution.
  • cargo test -p imageflow_core --test integration -- --skip visuals
    — 99 pass, 0 fail, 3 ignored (pre-existing).
  • cargo test -p imageflow_core --features schema-export,json-schema hash_files_relevant_to_schema_and_compare — pass, schema + hash
    regenerated and committed.
  • CI green on all platforms (Linux x86_64, Linux i686, macOS
    Intel, macOS ARM, Windows, windows-11-arm) — pending.

Commits

  1. feat(types): ImageFormat enum, FormatKillbits, CodecKillbits, Named{Encoder,Decoder}Name, build_killbits consts, ExecutionSecurity extensions, EncodeAnnotations / CodecSubstitutionAnnotation / SubstitutionReason, EncodeResult.annotations
  2. refactor(core): scope per-job security; stop mutating Context.security
  3. feat(core): trusted_policy + v1/context/{set_policy,get_net_support} endpoints
  4. feat(core): enforce format + codec killbits at decode/encode dispatch; substitute same-format codec for denied preset + annotate; format_not_available when no substitute available
  5. test: killbits + substitution + scoping unit + integration coverage (types + core)
  6. chore: regenerate OpenAPI schema hash

Draft — not for merge. Opened to invite review of the surface /
enforcement points / error + annotation shape before widening.

lilith added a commit that referenced this pull request Apr 20, 2026
…text.security

Previously `Build001`/`Execute001` inline `security` blocks mutated
`Context.security` in place, leaking per-job intent across subsequent
jobs on the same Context. That persisted even after #720's
intersection-only change — the mutation was still there, just
intersected before being applied.

Fix the root cause:

* Split `Context.security` into `default_job_security` (Context-scoped,
  set only by `v1/context/set_policy`) and `active_job_security`
  (per-job, scoped to the lifetime of a single build/execute).
* Add pure `Context::effective_security(&self, inline)` returning
  `trusted_policy ∩ default_job_security ∩ inline` — no mutation.
* `build_inner`/`execute_inner` validate the inline block, compute
  the effective value, install it into `active_job_security` via
  `JobSecuritySnapshot::install`, run the job, then restore.
* `Context::current_security()` is the single read point for per-node
  limit checks (`max_decode_size`, `max_frame_size`, `max_encode_size`,
  `max_input_file_bytes`, `max_total_file_pixels`) — returns the active
  value when mid-job, the default otherwise.
* `net_support()` prefers the already-intersected `active_job_security`
  over any job_request parameter so every dispatch point reads the
  same grid.
* `configure_security` is removed; its policy-install behaviour lives
  on as `apply_trusted_policy`, reachable only from
  `set_trusted_policy`.

`max_json_bytes` is still read pre-parse from `default_job_security`
(the parse has to know the cap before it can see the job's own
security block). That's the one field that stays Context-scoped.

The existing three-layer killbits tests continue to pass unchanged.
@lilith
lilith force-pushed the feat/killbits-three-layer branch from a6a1b62 to d32b52b Compare April 21, 2026 00:28
lilith added a commit to imazen/imageflow-dotnet that referenced this pull request Apr 21, 2026
Widen Imageflow.NativeRuntime.* / Imageflow.NativeTool.* version ranges
from the implicit "2.3.1-rc01 or higher" to "[2.3.1-rc01, 4.0.0)" so
restores will pick up a v3.x native runtime once imazen/imageflow#720
ships a killbits-capable prerelease, without bumping csprojs again.

Range choice:
- Floor remains 2.3.1-rc01 — NuGet's lowest-applicable rule keeps
  today's resolution unchanged on existing v2-only feeds.
- Prerelease lower bound is required: no stable 2.x exists, and a
  stable lower bound ([2.0.0, 4.0.0)) would exclude all prereleases
  and fail to restore.
- Upper cap <4.0.0 permits v3 adoption while preventing unintended
  jumps across a future v4 major.

Scope:
- src/Imageflow.AllPlatforms/Imageflow.AllPlatforms.csproj
  Imageflow.NativeRuntime.All: "2.3.1-rc01" -> "[2.3.1-rc01, 4.0.0)"
- tests/Imageflow.Test/Imageflow.Test.csproj
  All 7 Imageflow.NativeTool.<rid> entries: same widening.
- Regenerated packages.lock.json for both projects; resolved versions
  remain 2.3.1-rc01 (verified via lowest-applicable).

Out of scope (deliberate):
- tests/Imageflow.TestDotNetFull/*.csproj + packages.config stay
  pinned to 2.3.1-rc01. packages.config-style restore embeds the
  package version into the .targets import path
  (src/packages/Imageflow.NativeRuntime.win-x86_64.2.3.1-rc01/
  build/net45/...); ranges are not supported in that format. v3 may
  also change the targets file layout. This project is legacy
  net462/net472 gated behind the test_legacy matrix on Windows.
- Client code is untouched. SetPolicy / GetNetSupport / killbits
  endpoints ride on the existing imageflow_context_send_json ABI;
  calls against a v2 native correctly return InvalidMessageEndpoint,
  and KillbitsIntegration tests are already gated behind
  IMAGEFLOW_HAS_KILLBITS=1 (unchanged).

Verified:
- dotnet restore ./src/Imageflow.dncore.sln --force-evaluate: OK
- dotnet build ./src/Imageflow.dncore.sln -c Release: 0 errors across
  net8.0 / net9.0 / net10.0 / netstandard2.0 / netstandard2.1.
- dotnet test --framework net10.0 --filter "Category!=KillbitsIntegration":
  150 passing (matches PR #68 baseline).
- dotnet test --framework net8.0 --filter same: 150 passing.

Follow-up when a v3 killbits-capable prerelease publishes:
- Bump Imageflow.Test's floor to [3.0.0-alpha.0, 4.0.0) to force v3
  selection and exercise KillbitsIntegration under IMAGEFLOW_HAS_KILLBITS=1.
- Imageflow.AllPlatforms can keep [2.3.1-rc01, 4.0.0) so downstream
  consumers still build against v2 by default until v3 is ready.
@lilith
lilith force-pushed the feat/killbits-three-layer branch 2 times, most recently from 78c0f45 to 3fe6a96 Compare April 21, 2026 05:24
lilith added 6 commits April 20, 2026 23:54
…ncoder,Decoder}Name, build_killbits consts, ExecutionSecurity extensions

Introduce the three-layer killbits system at the types level.

- `ImageFormat` enum — the canonical list of codec-eligible formats.
- `FormatKillbits` / `FormatPermissions` / `FormatGrid` — per-format
  decode/encode gating with intersection semantics.
- `CodecKillbits` + `NamedEncoderName` / `NamedDecoderName` — finer-
  grained per-codec gating (e.g. allow zen_jpeg_encoder but not mozjpeg)
  layered on top of the format-level grid.
- `build_killbits` — compile-time `COMPILE_DENY_DECODE` /
  `COMPILE_DENY_ENCODE` const arrays populated from feature-gate
  enablement, used later by the core to compute the compile ceiling.
- `ExecutionSecurity::{formats, codecs}` — new optional boxed fields
  carrying the per-format / per-codec killbits. Boxed to keep the
  struct small (~120 bytes of inline list capacity stays on the heap).
- `SetPolicyRequest` wire type for `v1/context/set_policy`.
- Validation helpers (`KillbitsValidationError`, `CodecKillbitsJobLevelError`,
  etc.) surface mutual-exclusion errors and job-level "deny-only"
  violations as structured error values.

Pure types crate; no core wiring yet.
Previously `Build001`/`Execute001` inline `security` blocks mutated
`Context.security` in place, leaking per-job intent across subsequent
jobs on the same Context.

Fix the root cause:

* Rename `Context.security` to `default_job_security` (Context-scoped
  defaults). Never mutated by job-level `security` JSON — job-level
  intent lives in `active_job_security` for the lifetime of the job.
* Add `Context.active_job_security: Option<Box<ExecutionSecurity>>` —
  per-job effective value, set for the duration of a single
  `build`/`execute` call and cleared when the job finishes. Boxed to
  keep the idle-Context footprint small.
* Add pure `Context::effective_security(&self, inline)` returning
  `default_job_security ∩ inline` — no mutation.
* Add `JobSecuritySnapshot::install` / `restore` — scope guard that
  swaps `active_job_security` for the job body and restores afterward.
* `build_inner` / `execute_inner` validate the inline block, compute
  the effective value, install it via `JobSecuritySnapshot`, run the
  job, then restore.
* `Context::current_security()` is the single read point for per-node
  limit checks (`max_decode_size`, `max_frame_size`, `max_encode_size`,
  `max_input_file_bytes`, `max_total_file_pixels`) — returns the active
  value when mid-job, the default otherwise.
* Codec decoders (gif, png, webp, zen) and encode-path limit checks
  (`execution_engine`, `codecs_and_pointer`) route through
  `current_security()` instead of reading `ctx.security` directly.
* `configure_security` is removed; the per-job mutation path is gone.

`max_json_bytes` is still read pre-parse from `default_job_security`
(the parse has to know the cap before it can see the job's own
security block). That's the one field that stays Context-scoped.

The trusted-policy layer (layer 2) is introduced in a follow-up
commit; this commit strictly fixes the job-scoping bug and exposes the
hooks the trusted layer will plug into.
…endpoints

Layer 2 of the three-layer killbits system: a Context-scoped trusted
policy set once via a new JSON endpoint, intersected with the
per-job effective security on every build/execute.

Context:

- `Context.trusted_policy: Option<Box<ExecutionSecurity>>` — set at
  most once per Context (from `v1/context/set_policy`). Boxed to
  keep the idle-Context footprint small.
- `Context::set_trusted_policy(policy, require_unlocked)` — installs
  the policy. Narrow-only on re-lock; rejects `allow_*` entries
  naming formats denied at build time.
- `Context::effective_security` now intersects `trusted_policy ∩
  default_job_security ∩ inline` (layer 1 ∩ layer 2 ∩ layer 3).
- `Context::net_support` / `Context::codec_support` — return the
  effective format and codec grids.

New `imageflow_core::killbits` module:

- `feature_compiled_in(format, op)` — runtime feature-flag map for
  the c-codecs / zen-codecs gates.
- `parse_format_name` / `from_output_format` — string/enum to
  `ImageFormat`.
- `compute_net_support` / `compute_net_support_with_codecs` /
  `build_ceiling_grid` — grid math across layers 1..3.
- `enforce(grid, op, format)` — produces structured
  `decode_not_available` / `encode_not_available` errors when denied.
- `intersect_security` / `ensure_narrowing` / `validate_trusted_*`
  helpers for policy installation and per-job merging.
- `NetSupport` / `CompileCeiling` / `LockedPolicyReport` /
  `CodecSupportGrid` / `FormatGridView` wire types.

`NamedDecoders::wire_name` / `image_format` and
`NamedEncoders::wire_name` expose the symmetric mappings the killbits
grid uses to enumerate live backends.

Endpoints (via the existing `imageflow_context_send_json` entry
point; no new C ABI):

- `v1/context/set_policy` — request body
  `{ policy: ExecutionSecurity, require_unlocked?: bool }`. Sets the
  trusted policy once; later calls are accepted only when narrowing.
  Response echoes the resulting `net_support` grid.
- `v1/context/get_net_support` — no request body. Returns the current
  grid, whether a trusted policy is set, and a `compile_ceiling`
  summary (denied decode, denied encode, and formats with no
  compiled-in backend).

The OpenAPI schema regeneration lands in a follow-up commit.
Gate every live decode/encode dispatch on the net_support grid
(layer 1 ∩ layer 2 ∩ layer 3) and the codec-level kill lists.

Decode dispatch (`EnabledCodecs::create_decoder_for_magic_bytes`):

- Compute the effective grid once per request; call
  `killbits::enforce(grid, Op::Decode, format)` for each matching
  decoder. If the format is denied, return a structured
  `decode_not_available` error.
- Codec-level kill: when a matching decoder's wire name is denied by
  trusted/active codec killbits, skip it and try the next matching
  decoder (mozjpeg killed → fall through to image-rs JPEG). Only
  error `codec_not_available` when every matching decoder is dead.
- Distinguish "no decoder handled magic bytes" from "every matching
  decoder was killed" so operators can diagnose configuration vs.
  unsupported-input errors.

Encode dispatch (`codecs/auto.rs`):

- `auto::create_encoder` and `EncoderPreset` parsing call
  `killbits::enforce(grid, Op::Encode, format)` with the output
  format derived from the preset. Denied formats produce
  `encode_not_available` errors before any encoder is instantiated.
- When a format's preferred encoder is killed, fall back to the
  highest-priority live encoder for that format; return
  `codec_not_available` only when all encoders for the format are
  denied.

Every denial path carries a structured JSON payload (`error`,
`format`, `reasons`) that the ABI layer passes through unchanged.
Broad test surface for the three-layer killbits system:

Types-level unit tests (in `imageflow_types::killbits`): killbits
`validate`, `validate_job_level`, `intersect`, `apply_to` coverage
for both `FormatKillbits` and `CodecKillbits`. Verifies mutual
exclusion (allow_* xor deny_* xor formats table), job-level
"deny-only" rejection, and that intersection is the union of the
two layers' denies.

Core-level unit tests (in `imageflow_core::killbits`):
`build_ceiling_grid` reflects feature gates, `intersect_security`
narrows scalar limits and combines killbits, `compute_net_support`
folds the three layers, `codec_not_available_error` surfaces
structured JSON.

Integration tests (`imageflow_core/tests/integration/killbits.rs`):

- End-to-end: `Context::set_trusted_policy` rejects `allow_decode`
  naming format denied at build time; accepts narrowing re-locks;
  errors on widening re-locks.
- Dispatch: `create_decoder_for_magic_bytes` returns
  `decode_not_available` when the format is killed, and
  `codec_not_available` when every matching decoder is killed.
- Encode: `create_encoder` returns `encode_not_available` / falls
  back to a live encoder when the preferred one is killed.
- Scoping regression (`inline_max_decode_size_is_not_persisted_across_jobs`,
  `inline_killbits_is_not_persisted_across_jobs`,
  `trusted_policy_persists_but_inline_does_not`): confirms that
  `default_job_security` is untouched by inline job security, and
  that trusted-policy-installed scalar limits persist while inline
  denies do not.
- Cache: `net_support_cache_hands_out_same_arc_when_policy_unchanged`,
  `net_support_cache_is_invalidated_by_set_policy`,
  `net_support_per_job_inline_security_bypasses_cache`,
  `compile_ceiling_is_process_static` pin the caching contract.
Pick up the new `v1/context/set_policy` and `v1/context/get_net_support`
endpoints plus the `ExecutionSecurity.formats` / `ExecutionSecurity.codecs`
fields and the `ImageFormat` / `FormatPermissions` / `FormatKillbits` /
`CodecKillbits` / `NamedEncoderName` / `NamedDecoderName` schemas in
`openapi_schema_v1.json`. Updated hash in `openapi_schema_v1.json.hash`
so the `hash_files_relevant_to_schema_and_compare` drift test passes.

Generated by `cargo test -p imageflow_core --features
schema-export,json-schema` (the test rewrites both files in place).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant