jobpayload 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6a279c0d7d4f97b7ba76677146e0d73ebd7b65df8db221b93b61a6e69e1d30a
4
- data.tar.gz: 4dc6bc66b1296cf30bf03e1ebad5d68797868f6cfc84117edc9246b555a63881
3
+ metadata.gz: 4412fc4ef1f876b11092e07462d6a46a998c3c063a8b7576ff33dd871a53cf63
4
+ data.tar.gz: 514ada469e5d6ea61b44315f03b0e2bb946204b2c072bb58681adb3af24213e9
5
5
  SHA512:
6
- metadata.gz: e7b0ffe7184e08ca69623c383dd9ebb4f323408ea7a343d3a31accb0c310009c672e00af66411e78583d66545780c326956f098ce70ce86315fef4f1b3f9d10e
7
- data.tar.gz: 2dba7bc4c7dbb0786367ae438d94accedf7312f99f9c3733dfc7a3845f2fcf6153e72d260edcfca8fb486fb62f15327fe2008a44bfa9a3f6fe5c50fdf726e093
6
+ metadata.gz: 0134ac75049008ed572e7d73c1b978441642dce567e945d44071f81ab3decab7be51f33631caf37953fef7839e88c5cb1215e7bcc96589f6d708f049f76fa650
7
+ data.tar.gz: ccd929aba8e3dd698580fb00f6a643b02fcc7b551c8e4f65be6686569e6bb47e93657b40fedc019fde90ffeea48f746b1c7679d3f024a6a5076ba5f0e9063ea6
data/CHANGELOG.md CHANGED
@@ -2,6 +2,65 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 0.2.0
6
+
7
+ 0.2.0 adds snapshot drift detection. Everything 0.1.0 does is unchanged:
8
+ `snapshot`, `snapshot --update` and `check` behave as before, and the fixture
9
+ schema (`jobpayload_schema: 1`), the JSON output schema (`schema_version: 1`),
10
+ finding codes and exit codes are the same. There are no breaking changes.
11
+
12
+ ### Added
13
+
14
+ - `jobpayload snapshot --check`: serializes every snapshot case with the
15
+ current code and compares it with the baseline fixture already stored in
16
+ `--output`, to catch unintended changes to the payloads new code enqueues.
17
+ - **Read-only:** nothing in `--output` is written, created, renamed or
18
+ deleted, not even a missing `--output` directory. Cannot be combined with
19
+ `--update`.
20
+ - **Comparison:** the whole `job` object, as canonical JSON. The current
21
+ payload is normalized as `snapshot` writes it; the stored baseline is
22
+ compared as stored (never re-normalized), so any edit to it, volatile
23
+ metadata included, is reported. `source` metadata, key order, whitespace
24
+ and equivalent JSON spellings are ignored; value types (`1` vs `1.0`) and
25
+ array order are not.
26
+ - **Statuses and exit codes:** `identical` (0), `different` (1), `missing`
27
+ (1) and `invalid` (2); the highest exit status wins (2 > 1 > 0). A
28
+ baseline is `invalid` when it is not valid schema v1 JSON, is a symbolic
29
+ link (never followed), is not a regular file, cannot be read, or holds a
30
+ `job` that `snapshot` could never have written. Fixtures with no matching
31
+ case are ignored.
32
+ - **Output:** deterministic text, or JSON (`schema_version: 1`,
33
+ `"mode": "check"`). It reports whether each baseline differs, not where
34
+ (no field-level diff).
35
+
36
+ ### Security
37
+
38
+ - `snapshot --check` reads baselines in a way that resists the fixture's
39
+ directory entry being replaced during the check: after `lstat`, the file
40
+ is opened read-only with `O_NOFOLLOW` and `O_NONBLOCK`, verified with
41
+ `fstat` to be the same regular file (device and inode), and read only from
42
+ that descriptor. An entry swapped for a symlink, FIFO, directory or another
43
+ file, or removed, is `invalid` (exit 2) and never read, so it cannot leak
44
+ file contents into the output, block the check, or make it pass. Platforms
45
+ without these flags fail closed. This protects the final path component
46
+ only; see README "Race-safe baseline reading" for what is not covered.
47
+ The hardening was made before `snapshot --check` was released, so no
48
+ released version is affected. `jobpayload check` is unchanged (it reads by
49
+ path and follows symbolic links, as in 0.1.0).
50
+
51
+ ### Changed
52
+
53
+ - Internal: `JobPayload::Fixture.parse_content` parses fixture bytes the
54
+ caller has already read; `Fixture.parse` keeps its behaviour and shares the
55
+ same validation.
56
+ - Tests and CI (no runtime change): tests for `snapshot --check` and its
57
+ race-safe reads (deterministic lstat/open races, FIFO deadlines, descriptor
58
+ leaks, real-process runs); a macOS smoke job; capability-based skips for
59
+ file names that are not valid UTF-8; and a separate Rails `main` GlobalID
60
+ regression workflow (not a supported version).
61
+ - README: documents `snapshot --check`, its security boundaries and the
62
+ updated version references.
63
+
5
64
  ## 0.1.0
6
65
 
7
66
  Initial release. The single question v0.1.0 answers is: *can the current
data/README.md CHANGED
@@ -16,6 +16,7 @@ application can still be deserialized by the **new** version.
16
16
  - [Quick start](#quick-start)
17
17
  - [Creating snapshot cases](#creating-snapshot-cases)
18
18
  - [Generating baseline fixtures](#generating-baseline-fixtures)
19
+ - [Detecting snapshot drift (`snapshot --check`)](#detecting-snapshot-drift-snapshot---check)
19
20
  - [Checking old fixtures on new code](#checking-old-fixtures-on-new-code)
20
21
  - [Two-way rolling-deploy checking](#two-way-rolling-deploy-checking)
21
22
  - [GlobalID / inconclusive semantics](#globalid--inconclusive-semantics)
@@ -28,6 +29,7 @@ application can still be deserialized by the **new** version.
28
29
  - [Non-goals](#non-goals)
29
30
  - [Known limitations](#known-limitations)
30
31
  - [Security / safety notes](#security--safety-notes)
32
+ - [Race-safe baseline reading (`snapshot --check`)](#race-safe-baseline-reading-snapshot---check)
31
33
 
32
34
  ## What jobpayload checks
33
35
 
@@ -47,7 +49,10 @@ A baseline fixture is compatible with the current application when:
47
49
  subclasses, nested arrays/hashes and GlobalID lookups, in your real
48
50
  (test) environment.
49
51
 
50
- v0.1.0 checks **backward-read compatibility only**: old payload → new code.
52
+ `check` checks **backward-read compatibility only**: old payload → new code.
53
+ Since v0.2.0, `snapshot --check` can also detect *drift*: whether the current
54
+ code still serializes each snapshot case exactly as its stored baseline
55
+ (see [Detecting snapshot drift](#detecting-snapshot-drift-snapshot---check)).
51
56
 
52
57
  ## Why queued payload compatibility matters
53
58
 
@@ -150,6 +155,7 @@ bundle exec jobpayload snapshot [options]
150
155
  | `--boot PATH` | `config/environment.rb` | File required to boot the app |
151
156
  | `--environment NAME` | `test` | Value assigned to `RAILS_ENV` before booting |
152
157
  | `--update` | off | Replace existing fixtures whose content changed |
158
+ | `--check` | off | Compare with existing fixtures without writing (see [Detecting snapshot drift](#detecting-snapshot-drift-snapshot---check)) |
153
159
  | `--format text\|json` | `text` | Output format |
154
160
 
155
161
  Behaviour:
@@ -193,7 +199,7 @@ One JSON file per fixture, schema v1:
193
199
  "name": "billing-money-v1",
194
200
  "source": {
195
201
  "active_job_version": "8.1.4",
196
- "jobpayload_version": "0.1.0",
202
+ "jobpayload_version": "0.2.0",
197
203
  "rails_version": "8.1.4",
198
204
  "ruby_version": "3.4.8"
199
205
  }
@@ -213,6 +219,93 @@ One JSON file per fixture, schema v1:
213
219
  two-space layout (independent of the json gem version), UTF-8, LF line endings,
214
220
  a final newline, written atomically (temp file + rename).
215
221
 
222
+ ### Detecting snapshot drift (`snapshot --check`)
223
+
224
+ ```sh
225
+ bundle exec jobpayload snapshot --check [options]
226
+ ```
227
+
228
+ `--check` serializes every snapshot case with the current code, exactly as
229
+ `snapshot` would, and compares the result with the fixture already stored in
230
+ `--output`. It answers "did the payload this code writes change?", so an
231
+ unintended change to what new code enqueues fails CI before it ships. It is
232
+ **read-only for the fixtures**: nothing in `--output` is written, replaced,
233
+ created or deleted, not even a missing `--output` directory. (Like `snapshot`,
234
+ it still boots your application and runs your cases file, which may write to
235
+ the test database.) `--check` and `--update` cannot be combined.
236
+
237
+ Only the `job` object is compared, and every key in it counts. The current
238
+ payload is normalized exactly as `snapshot` writes it (fixed `job_id`,
239
+ `enqueued_at` and so on); the stored `job` is compared as stored, so any edit
240
+ to a fixture's `job`, volatile metadata included, is reported as `different`.
241
+ `source` metadata, key order, whitespace and equivalent JSON spellings
242
+ (`"\u00e9"` and `"é"`, `1.0e2` and `100.0`) are ignored. Value types and array
243
+ order are kept: `1` and `1.0`, or `null` and a missing key, are different
244
+ payloads.
245
+
246
+ | Status | Meaning | Exit |
247
+ | --- | --- | --- |
248
+ | `identical` | The stored `job` matches what the current code serializes | 0 |
249
+ | `different` | The payload the current code writes differs from the stored `job` | 1 |
250
+ | `missing` | No fixture file for the case | 1 |
251
+ | `invalid` | The fixture cannot be used: invalid JSON or UTF-8, wrong schema, name mismatch, a symbolic link (never followed, even when it points to a valid fixture), not a regular file (such as a directory or FIFO), replaced or removed while it is being checked, unreadable or inaccessible (for example an unsearchable directory), or a `job` that `snapshot` could never have written (a non-finite number such as `1e400`, or nesting deeper than `snapshot`'s limit of 100) | 2 |
252
+
253
+ The exit status is the highest that applies (`2` > `1` > `0`). Case errors,
254
+ boot errors and usage errors exit 2 as for `snapshot`. Fixtures with no
255
+ matching case are ignored. A `--output` directory that does not exist is not an
256
+ error: every case is reported `missing` (exit 1), so check the path if
257
+ everything is missing. Only the fixture file entry itself must not be a symbolic
258
+ link; `--output` may be one. Baselines are read in a way that
259
+ resists the file being swapped during the check; see
260
+ [Race-safe baseline reading](#race-safe-baseline-reading-snapshot---check).
261
+
262
+ Text output has one line per case, in name order, and a summary. The status
263
+ says whether a fixture differs, not where; compare the files (for example
264
+ with `git diff` after `snapshot --update`) to see the change:
265
+
266
+ ```text
267
+ identical billing-legacy-money-v1 test/jobpayload_fixtures/billing-legacy-money-v1.json
268
+ different billing-money-v1 test/jobpayload_fixtures/billing-money-v1.json
269
+ missing scheduled-v1 test/jobpayload_fixtures/scheduled-v1.json
270
+
271
+ 3 fixtures: 1 identical, 1 different, 1 missing, 0 invalid
272
+ ```
273
+
274
+ With `--format json` the document (schema v1) is:
275
+
276
+ ```json
277
+ {
278
+ "schema_version": 1,
279
+ "tool_version": "0.2.0",
280
+ "mode": "check",
281
+ "status": "fail",
282
+ "summary": {
283
+ "fixtures": 1,
284
+ "identical": 0,
285
+ "different": 1,
286
+ "missing": 0,
287
+ "invalid": 0
288
+ },
289
+ "fixtures": [
290
+ {
291
+ "name": "billing-money-v1",
292
+ "path": "test/jobpayload_fixtures/billing-money-v1.json",
293
+ "status": "different",
294
+ "reason": null
295
+ }
296
+ ]
297
+ }
298
+ ```
299
+
300
+ `status` is `pass`, `fail` or `tool_error` for exit 0, 1 and 2. `reason` is
301
+ set only for `invalid`.
302
+ Text and JSON output are deterministic for the same input.
303
+
304
+ A `different` result is not a compatibility failure by itself: it means the
305
+ new code writes a different payload than the fixture records. Add a new case
306
+ for the new format (keeping the old fixture for `check`), or re-run
307
+ `snapshot --update` if the fixture was meant to follow the code.
308
+
216
309
  ## Checking old fixtures on new code
217
310
 
218
311
  ```sh
@@ -310,7 +403,8 @@ database (seeds, fixtures, or a boot file) to get a conclusive answer.
310
403
 
311
404
  ## Finding codes
312
405
 
313
- Finding codes are a stable API for 0.1.x.
406
+ Finding codes are a stable API: 0.2.0 keeps every 0.1.0 code and its meaning
407
+ unchanged.
314
408
 
315
409
  | Code | Name | Kind | Meaning |
316
410
  | --- | --- | --- | --- |
@@ -331,6 +425,10 @@ Finding codes are a stable API for 0.1.x.
331
425
 
332
426
  When several apply, the highest priority wins: `2` > `1` > `0`.
333
427
 
428
+ These are the exit statuses of `check`. `snapshot --check` uses the same
429
+ `2` > `1` > `0` precedence with its own meanings; see
430
+ [Detecting snapshot drift](#detecting-snapshot-drift-snapshot---check).
431
+
334
432
  Results are written to **stdout**. Usage, configuration and boot errors are
335
433
  written to **stderr** (and nothing is written to stdout in that case).
336
434
  jobpayload itself writes nothing else to stdout. While booting and
@@ -401,6 +499,9 @@ A summary line `N tool error(s)` is added when fixtures had fatal findings.
401
499
 
402
500
  ## JSON output
403
501
 
502
+ This section describes `check --format json`. The `snapshot --check` document
503
+ is described in [Detecting snapshot drift](#detecting-snapshot-drift-snapshot---check).
504
+
404
505
  ```sh
405
506
  bundle exec jobpayload check --format json
406
507
  ```
@@ -408,7 +509,7 @@ bundle exec jobpayload check --format json
408
509
  ```json
409
510
  {
410
511
  "schema_version": 1,
411
- "tool_version": "0.1.0",
512
+ "tool_version": "0.2.0",
412
513
  "status": "fail",
413
514
  "summary": {
414
515
  "fixtures": 3,
@@ -467,7 +568,7 @@ bundle exec jobpayload check --format json
467
568
 
468
569
  | Field | Meaning |
469
570
  | --- | --- |
470
- | `schema_version` | JSON output schema version. Always `1` in 0.1.x. |
571
+ | `schema_version` | JSON output schema version. Always `1` in 0.1.x and 0.2.0. |
471
572
  | `tool_version` | jobpayload version. |
472
573
  | `status` | `"pass"` (exit 0), `"fail"` (exit 1) or `"tool_error"` (exit 2). With `--fail-on-inconclusive`, inconclusive fixtures make it `"fail"`. |
473
574
  | `summary.fixtures` | Number of fixture files checked. |
@@ -487,7 +588,7 @@ Backtraces are never included in JSON output. The same input always produces
487
588
  byte-identical JSON. Exception messages, fixture file names and paths are
488
589
  emitted as valid UTF-8 (invalid bytes become U+FFFD).
489
590
 
490
- **Stability policy.** Within `schema_version: 1` (all 0.1.x releases),
591
+ **Stability policy.** Within `schema_version: 1` (0.1.x and 0.2.0),
491
592
  existing fields are never removed or renamed, and the meaning and allowed
492
593
  values of existing fields do not change. New fields may be added, so consumers
493
594
  should ignore fields they do not know. Any incompatible change bumps
@@ -514,13 +615,15 @@ jobs:
514
615
  bundler-cache: true
515
616
  - run: bin/rails db:prepare
516
617
  - run: bundle exec jobpayload check
618
+ # Optional: fail when the code changes the payloads it writes.
619
+ - run: bundle exec jobpayload snapshot --check
517
620
  ```
518
621
 
519
622
  For two-way checking, check out both the base and the head revision (for
520
623
  example two `actions/checkout` steps with different `path:` and `ref:`), then
521
624
  run `check` twice: base fixtures → head app, and head fixtures → base app, as
522
625
  shown in [Two-way rolling-deploy checking](#two-way-rolling-deploy-checking).
523
- jobpayload 0.1.0 does not orchestrate checkouts itself.
626
+ jobpayload does not orchestrate checkouts itself.
524
627
 
525
628
  ## Supported Ruby / Active Job versions
526
629
 
@@ -543,7 +646,7 @@ built-in fixture set.
543
646
 
544
647
  ## Non-goals
545
648
 
546
- v0.1.0 deliberately does **not**:
649
+ jobpayload deliberately does **not**:
547
650
 
548
651
  - execute `perform` or any job business logic, enqueue, or retry jobs
549
652
  - check `perform` arity or execution semantics. A successful deserialize does
@@ -558,6 +661,8 @@ v0.1.0 deliberately does **not**:
558
661
  - orchestrate forward/two-way compatibility automatically
559
662
  - re-serialize deserialized arguments (round-trip / re-enqueue compatibility)
560
663
  - provide ignore/suppression lists or a configuration file
664
+ - show field-level (structural) diffs: `snapshot --check` reports only
665
+ whether a baseline differs, not where
561
666
 
562
667
  ## Known limitations
563
668
 
@@ -567,6 +672,8 @@ v0.1.0 deliberately does **not**:
567
672
  never writes more than 100). Such a fixture is reported as `AJP900` with a
568
673
  `SystemStackError` cause, or the check stops with an internal error; either
569
674
  way the exit status is 2, never 0 or 1.
675
+ `snapshot --check` reports a baseline nested deeper than 100 levels as
676
+ `invalid` (exit 2).
570
677
  - **GlobalIDs inside custom serializer payloads.** Only a top-level or
571
678
  plain-array/hash GlobalID argument can be inconclusive. If a custom
572
679
  serializer's own payload contains a GlobalID whose record is missing, the
@@ -578,6 +685,13 @@ v0.1.0 deliberately does **not**:
578
685
  [Exit codes](#exit-codes) and can corrupt `--format json` output.
579
686
  - **Semantic changes** that still deserialize successfully pass (see
580
687
  [Non-goals](#non-goals)).
688
+ - **File names that are not valid UTF-8.** jobpayload passes the exact bytes
689
+ it is given for `--output`, `--fixtures`, `--cases` and `--boot` to the file
690
+ system, and only replaces invalid bytes with U+FFFD in text and JSON output.
691
+ Whether such a name can exist at all depends on the file system: most Linux
692
+ file systems accept any bytes, while macOS (APFS) rejects names that are
693
+ not valid UTF-8, so jobpayload cannot create them there. Ordinary UTF-8
694
+ paths work on every platform.
581
695
  - **Database errors during GlobalID lookup** (`ActiveRecord::StatementInvalid`,
582
696
  for example a column or table that the current schema no longer has) are
583
697
  reported as `AJP900` with exit 2, not as `AJP201`. The run still fails.
@@ -601,12 +715,55 @@ v0.1.0 deliberately does **not**:
601
715
  at production databases. Note that a `DATABASE_URL` set in your shell is
602
716
  still used by Rails in the `test` environment.
603
717
 
718
+ ### Race-safe baseline reading (`snapshot --check`)
719
+
720
+ `snapshot --check` reads each baseline so that the fixture's directory entry
721
+ cannot be swapped between the type check and the read:
722
+
723
+ 1. `lstat` the fixture path. A symbolic link, directory, FIFO or anything else
724
+ that is not a regular file is `invalid`; nothing is opened.
725
+ 2. Open it read-only with `O_NOFOLLOW` (a symbolic link put in its place is
726
+ refused instead of followed) and `O_NONBLOCK` (a FIFO put in its place
727
+ cannot block the open waiting for a writer). The open never creates or
728
+ truncates anything.
729
+ 3. `fstat` the open descriptor. It must be a regular file with the same device
730
+ and inode numbers `lstat` reported, so a different file renamed into place
731
+ is detected. Only then is anything read.
732
+ 4. Read the bytes from that same descriptor (the path is never opened again)
733
+ and parse them.
734
+
735
+ An entry that is replaced, turned into a symlink, FIFO or directory, or removed
736
+ between steps 1 and 2 is reported as `invalid` (exit 2), with a reason such as
737
+ `replaced by a different file after it was checked`; its contents are never
738
+ read, so they cannot leak into the output or make the check pass. If the
739
+ platform lacks `O_NOFOLLOW` or `O_NONBLOCK`, every baseline is `invalid`
740
+ rather than read without them.
741
+
742
+ What this does **not** guarantee:
743
+
744
+ - Only the final path component is protected. Parent directories, including a
745
+ symlinked `--output` directory (which is allowed), are resolved normally and
746
+ are trusted; replacing a parent directory is not detected.
747
+ - The device/inode check pins the file that was opened, not its contents: a
748
+ process that rewrites that same file while it is read is not detected.
749
+ - A hard link is a regular file and is read like any other.
750
+ - Something swapped in after `lstat` is opened before `fstat` can reject it.
751
+ Opening is non-blocking and never makes a terminal the controlling one, but
752
+ opening some device nodes can still have side effects of its own.
753
+ - Inode reuse, network file systems and unusual file systems may weaken these
754
+ checks; they are not a complete guarantee on every file system.
755
+ - It does not make untrusted code safe: your application and cases file still
756
+ run as described above.
757
+
758
+ `jobpayload check` is unchanged: it still reads its fixtures by path and
759
+ follows symbolic links, as in 0.1.0.
760
+
604
761
  ## Development
605
762
 
606
763
  ```sh
607
764
  bundle install
608
765
  bundle exec rake test # Minitest; randomized order
609
- bundle exec rake build # pkg/jobpayload-0.1.0.gem
766
+ bundle exec rake build # pkg/jobpayload-0.2.0.gem
610
767
 
611
768
  # Another Active Job version:
612
769
  BUNDLE_GEMFILE=gemfiles/activejob_7.2.gemfile bundle install
@@ -617,6 +774,28 @@ The test suite boots small Rails-free applications from `test/apps` (Active
617
774
  Job + Active Record on in-memory SQLite) in subprocesses: `v1` writes baseline
618
775
  fixtures, `v2_compatible` and `v2_breaking` model later versions of the same app.
619
776
 
777
+ Tests that need file names which are not valid UTF-8 probe the file system
778
+ of their own temporary directory and are skipped, with the reason, only when
779
+ it rejects such names (as on macOS).
780
+
781
+ CI runs:
782
+
783
+ - **Linux matrix** (`.github/workflows/ci.yml`): the full suite for every
784
+ supported Ruby / Active Job combination above.
785
+ - **macOS smoke test** (same workflow): the full suite on Ruby 3.4 / Active
786
+ Job 8.1.
787
+ - **Rails edge** (`.github/workflows/rails-edge.yml`, on pull requests,
788
+ weekly and on demand): GlobalID classification regression tests
789
+ (`test/edge`) against Rails `main` (`gemfiles/rails_edge.gemfile`), as a
790
+ separate workflow and check. This is an early warning about upstream
791
+ changes, not a supported version; its result is independent of the matrix
792
+ above, and a failure is reported as a failure.
793
+
794
+ ```sh
795
+ BUNDLE_GEMFILE=gemfiles/rails_edge.gemfile bundle install
796
+ BUNDLE_GEMFILE=gemfiles/rails_edge.gemfile bundle exec rake test:rails_edge
797
+ ```
798
+
620
799
  ## License
621
800
 
622
801
  MIT. See [LICENSE](LICENSE).
@@ -98,10 +98,13 @@ module JobPayload
98
98
  opts.on("--output DIR", "Fixture output directory (default: #{DEFAULT_FIXTURES})") { |v| options[:output] = v }
99
99
  boot_options(opts, options)
100
100
  opts.on("--update", "Replace existing fixtures whose content changed") { options[:update] = true }
101
+ opts.on("--check", "Compare with existing fixtures without writing (exit 1 on drift)") { options[:check] = true }
101
102
  format_option(opts, options)
102
103
  end
103
104
  return EXIT_SUCCESS if parse(parser)
104
105
 
106
+ raise UsageError, "--check and --update cannot be used together" if options[:check] && options[:update]
107
+
105
108
  formatter_name = options[:format]
106
109
  raise ConfigurationError, "cases file not found: #{options[:cases]}" unless File.file?(options[:cases])
107
110
  if File.exist?(options[:output]) && !File.directory?(options[:output])
@@ -110,11 +113,20 @@ module JobPayload
110
113
 
111
114
  boot(options)
112
115
  registry = CaseRegistry.load(options[:cases])
116
+ return snapshot_check(registry, options[:output], formatter_name) if options[:check]
117
+
113
118
  entries = Snapshotter.new(registry: registry, output_dir: options[:output], update: options[:update]).call
114
119
  @stdout.print(formatter_name == "json" ? snapshot_json(entries) : snapshot_text(entries))
115
120
  EXIT_SUCCESS
116
121
  end
117
122
 
123
+ # `snapshot --check`: read-only drift detection against existing fixtures.
124
+ def snapshot_check(registry, fixtures_dir, formatter_name)
125
+ entries = SnapshotChecker.new(registry: registry, fixtures_dir: fixtures_dir).call
126
+ @stdout.print(formatter_name == "json" ? snapshot_check_json(entries) : snapshot_check_text(entries))
127
+ SnapshotChecker.exit_code(entries)
128
+ end
129
+
118
130
  def check
119
131
  options = {
120
132
  fixtures: DEFAULT_FIXTURES, boot: Boot::DEFAULT_PATH, environment: Boot::DEFAULT_ENVIRONMENT,
@@ -200,6 +212,34 @@ module JobPayload
200
212
  Canonical.pretty(document)
201
213
  end
202
214
 
215
+ def snapshot_check_text(entries)
216
+ lines = entries.map do |entry|
217
+ line = format("%-9s %s %s", entry.status, entry.name, JobPayload.scrub_utf8(entry.path))
218
+ entry.reason ? "#{line} (#{JobPayload.scrub_utf8(entry.reason)})" : line
219
+ end
220
+ counts = entries.map(&:status).tally
221
+ summary = SnapshotChecker::STATUSES.map { |status| "#{counts.fetch(status, 0)} #{status}" }.join(", ")
222
+ "#{lines.join("\n")}\n\n#{entries.size} fixtures: #{summary}\n"
223
+ end
224
+
225
+ def snapshot_check_json(entries)
226
+ counts = entries.map(&:status).tally
227
+ document = {
228
+ "schema_version" => 1,
229
+ "tool_version" => VERSION,
230
+ "mode" => "check",
231
+ "status" => SnapshotChecker.status(entries),
232
+ "summary" => { "fixtures" => entries.size }.merge(SnapshotChecker::STATUSES.to_h { |s| [s.to_s, counts.fetch(s, 0)] }),
233
+ "fixtures" => entries.map do |e|
234
+ {
235
+ "name" => e.name, "path" => JobPayload.scrub_utf8(e.path), "status" => e.status.to_s,
236
+ "reason" => e.reason && JobPayload.scrub_utf8(e.reason)
237
+ }
238
+ end
239
+ }
240
+ Canonical.pretty(document)
241
+ end
242
+
203
243
  def usage_error(message)
204
244
  @stderr.puts("jobpayload: #{message}")
205
245
  @stderr.puts("Run `jobpayload --help` for usage.")
@@ -3,7 +3,7 @@
3
3
  module JobPayload
4
4
  # One problem found while checking a fixture.
5
5
  #
6
- # Finding codes are a stable API: their meaning does not change within 0.1.x.
6
+ # Finding codes are a stable API: 0.2.0 keeps every 0.1.0 code and meaning.
7
7
  Finding = Struct.new(
8
8
  :code,
9
9
  :fixture,
@@ -24,9 +24,24 @@ module JobPayload
24
24
  end
25
25
 
26
26
  def self.parse(path)
27
+ build(path) { File.binread(path) }
28
+ end
29
+
30
+ # Like parse, for bytes the caller has already read from +path+ (for
31
+ # example through SecureFixtureReader); +path+ only names the fixture.
32
+ def self.parse_content(path, content)
33
+ build(path) { content }
34
+ end
35
+
36
+ def self.build(path)
27
37
  basename = File.basename(path, ".json")
28
38
  reason = catch(:invalid) do
29
- data = parse_json(path)
39
+ content = begin
40
+ yield
41
+ rescue SystemCallError => e
42
+ invalid!("cannot read file: #{e.message}")
43
+ end
44
+ data = parse_json(content)
30
45
  validate!(data, basename)
31
46
  return new(name: data["name"], path: path, job: data["job"], source: data["source"])
32
47
  end
@@ -35,8 +50,8 @@ module JobPayload
35
50
  Invalid.new(JobPayload.scrub_utf8(basename), path, JobPayload.scrub_utf8(reason))
36
51
  end
37
52
 
38
- def self.parse_json(path)
39
- content = File.binread(path).force_encoding(Encoding::UTF_8)
53
+ def self.parse_json(content)
54
+ content = content.dup.force_encoding(Encoding::UTF_8)
40
55
  invalid!("file is not valid UTF-8") unless content.valid_encoding?
41
56
  data = JSON.parse(content, max_nesting: false)
42
57
  # Some json versions decode a lone surrogate escape ("\udc80") into
@@ -45,8 +60,6 @@ module JobPayload
45
60
  data
46
61
  rescue JSON::ParserError => e
47
62
  invalid!("invalid JSON: #{e.message.lines.first.to_s.strip}")
48
- rescue SystemCallError => e
49
- invalid!("cannot read file: #{e.message}")
50
63
  end
51
64
 
52
65
  # Iterative, so that deeply nested fixtures cannot exhaust the stack here.
@@ -89,6 +102,6 @@ module JobPayload
89
102
  def self.invalid!(reason)
90
103
  throw :invalid, reason
91
104
  end
92
- private_class_method :parse_json, :valid_strings?, :validate!, :invalid!
105
+ private_class_method :build, :parse_json, :valid_strings?, :validate!, :invalid!
93
106
  end
94
107
  end
@@ -5,7 +5,7 @@ require "json"
5
5
  module JobPayload
6
6
  module Formatter
7
7
  # Machine-readable output, schema v1. Field names and meanings are stable
8
- # for 0.1.x; see README "JSON output".
8
+ # within schema_version 1; see README "JSON output".
9
9
  module JSON
10
10
  SCHEMA_VERSION = 1
11
11
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobPayload
4
+ # Reads a baseline fixture for `snapshot --check` without trusting the path
5
+ # to still name the entry that was checked with lstat.
6
+ #
7
+ # The file is opened read-only with O_NOFOLLOW (a final-component symlink is
8
+ # refused, not followed) and O_NONBLOCK (opening a FIFO never waits for a
9
+ # writer). The open descriptor is then checked with fstat: it must be a
10
+ # regular file with the device and inode lstat reported. Only then is
11
+ # anything read, and only from that descriptor; the path is never reopened.
12
+ #
13
+ # This defends against the fixture's directory entry being replaced between
14
+ # lstat and open. It does not pin parent directories, and it cannot stop a
15
+ # process from rewriting the verified file's contents while it is read.
16
+ module SecureFixtureReader
17
+ # content is set on success, reason otherwise.
18
+ Result = Struct.new(:content, :reason)
19
+
20
+ UNSUPPORTED = "cannot open file safely: this platform lacks O_NOFOLLOW or O_NONBLOCK"
21
+
22
+ module_function
23
+
24
+ # +expected+ is the File::Stat from File.lstat(path), already known to be
25
+ # a regular file.
26
+ def read(path, expected)
27
+ flags = open_flags
28
+ return Result.new(nil, UNSUPPORTED) unless flags
29
+
30
+ io = begin
31
+ File.new(path, flags, binmode: true)
32
+ rescue Errno::ENOENT
33
+ return Result.new(nil, "file disappeared after it was checked")
34
+ rescue *symlink_errors
35
+ return Result.new(nil, "replaced by a symbolic link after it was checked (not followed)")
36
+ rescue SystemCallError => e
37
+ return Result.new(nil, "cannot read file: #{e.message}")
38
+ end
39
+
40
+ begin
41
+ actual = io.stat
42
+ return Result.new(nil, "replaced by something that is not a regular file after it was checked") unless actual.file?
43
+ unless actual.dev == expected.dev && actual.ino == expected.ino
44
+ return Result.new(nil, "replaced by a different file after it was checked")
45
+ end
46
+
47
+ Result.new(read_verified(io), nil)
48
+ rescue SystemCallError, IOError => e
49
+ Result.new(nil, "cannot read file: #{e.message}")
50
+ ensure
51
+ io.close
52
+ end
53
+ end
54
+
55
+ # Read-only, no-follow, non-blocking; never creates or truncates. nil when
56
+ # the platform cannot provide that (Ruby defines missing flags as 0), so
57
+ # the caller fails closed instead of falling back to a plain read.
58
+ def open_flags(constants = File::Constants)
59
+ nofollow = constants.const_defined?(:NOFOLLOW) ? constants::NOFOLLOW : 0
60
+ nonblock = constants.const_defined?(:NONBLOCK) ? constants::NONBLOCK : 0
61
+ return nil if nofollow.zero? || nonblock.zero?
62
+
63
+ flags = constants::RDONLY | nofollow | nonblock
64
+ # Never become the controlling terminal if the entry was swapped for one.
65
+ flags |= constants::NOCTTY if constants.const_defined?(:NOCTTY)
66
+ flags
67
+ end
68
+
69
+ # Errors open(2) reports for a final-component symlink under O_NOFOLLOW:
70
+ # ELOOP on Linux and macOS, EMLINK on FreeBSD, EFTYPE on NetBSD.
71
+ def symlink_errors
72
+ %i[ELOOP EMLINK EFTYPE].filter_map { |name| Errno.const_get(name) if Errno.const_defined?(name) }
73
+ end
74
+
75
+ # Only ever called on a descriptor that passed the checks above.
76
+ def read_verified(io)
77
+ io.read
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module JobPayload
6
+ # Compares what the current application serializes for each snapshot case
7
+ # with the fixture already stored for it (`jobpayload snapshot --check`).
8
+ #
9
+ # Read-only: never writes, renames or creates anything. Only the `job`
10
+ # objects are compared, and every key in them counts. The current job is
11
+ # normalized exactly as `snapshot` writes it; the stored job is taken as
12
+ # is, so an edited baseline (including its volatile metadata) is never
13
+ # masked. Both are compared as canonical JSON: `source` metadata, key order
14
+ # and layout are ignored, while value types (1 and 1.0 differ, because
15
+ # Active Job reads them differently) and array order are kept.
16
+ class SnapshotChecker
17
+ # status: :identical, :different, :missing or :invalid; reason is set for
18
+ # :invalid only.
19
+ Entry = Struct.new(:name, :path, :status, :reason)
20
+
21
+ STATUSES = %i[identical different missing invalid].freeze
22
+
23
+ # Overall status => exit status.
24
+ EXIT_CODES = { "pass" => 0, "fail" => 1, "tool_error" => 2 }.freeze
25
+
26
+ def initialize(registry:, fixtures_dir:)
27
+ @registry = registry
28
+ @fixtures_dir = fixtures_dir
29
+ end
30
+
31
+ # Evaluates every case first (a failing case raises before anything is
32
+ # compared), then checks the fixtures in name order.
33
+ def call
34
+ snapshotter = Snapshotter.new(registry: @registry, output_dir: @fixtures_dir)
35
+ current = @registry.cases.map { |kase| [kase.name, snapshotter.build_document(kase)["job"]] }
36
+ current.map { |name, job| compare(name, job) }
37
+ end
38
+
39
+ # "tool_error" when any baseline is invalid, else "fail" when any differs
40
+ # or is missing, else "pass".
41
+ def self.status(entries)
42
+ statuses = entries.map(&:status)
43
+ return "tool_error" if statuses.include?(:invalid)
44
+ return "fail" if statuses.intersect?(%i[different missing])
45
+
46
+ "pass"
47
+ end
48
+
49
+ # 2 > 1 > 0, from status.
50
+ def self.exit_code(entries)
51
+ EXIT_CODES.fetch(status(entries))
52
+ end
53
+
54
+ # Canonical JSON for a stored job, without normalizing it. Raises
55
+ # ::JSON::JSONError when the job is not something `snapshot` could have
56
+ # written: a non-finite number (1e400 parses to Infinity) or nesting
57
+ # deeper than snapshot's limit of 100.
58
+ def self.canonical_baseline(job)
59
+ ::JSON.generate(job)
60
+ Canonical.generate(job)
61
+ end
62
+
63
+ private
64
+
65
+ # +current_job+ is already normalized (Snapshotter#build_document).
66
+ def compare(name, current_job)
67
+ path = File.join(@fixtures_dir, "#{name}.json")
68
+ stat = begin
69
+ File.lstat(path)
70
+ rescue Errno::ENOENT
71
+ return Entry.new(name, path, :missing, nil)
72
+ rescue SystemCallError => e
73
+ # For example an unsearchable fixture directory: not "never generated".
74
+ return invalid(name, path, "cannot access file: #{e.message}")
75
+ end
76
+ # From lstat, so a symlink is never followed, whatever it points to.
77
+ return invalid(name, path, "symbolic link (not followed)") if stat.symlink?
78
+ return invalid(name, path, "not a regular file") unless stat.file?
79
+
80
+ # Opened without following symlinks or blocking, and read only after the
81
+ # open file is confirmed to be the one lstat saw.
82
+ read = SecureFixtureReader.read(path, stat)
83
+ return invalid(name, path, read.reason) if read.reason
84
+
85
+ fixture = begin
86
+ Fixture.parse_content(path, read.content)
87
+ rescue SystemStackError => e
88
+ # Some json versions parse recursively; nesting deep enough to
89
+ # exhaust the stack must not abort the other fixtures.
90
+ return invalid(name, path, "job cannot be canonicalized: #{e.message}")
91
+ end
92
+ return invalid(name, path, fixture.reason) if fixture.is_a?(Fixture::Invalid)
93
+
94
+ baseline = begin
95
+ self.class.canonical_baseline(fixture.job)
96
+ rescue ::JSON::JSONError, ArgumentError, EncodingError, SystemStackError => e
97
+ return invalid(name, path, "job cannot be canonicalized: #{e.message}")
98
+ end
99
+ status = baseline == Canonical.generate(current_job) ? :identical : :different
100
+ Entry.new(name, path, status, nil)
101
+ end
102
+
103
+ def invalid(name, path, reason)
104
+ Entry.new(name, path, :invalid, reason)
105
+ end
106
+ end
107
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JobPayload
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/jobpayload.rb CHANGED
@@ -15,6 +15,8 @@ module JobPayload
15
15
  autoload :Boot, "jobpayload/boot"
16
16
  autoload :Canonical, "jobpayload/canonical"
17
17
  autoload :Snapshotter, "jobpayload/snapshotter"
18
+ autoload :SnapshotChecker, "jobpayload/snapshot_checker"
19
+ autoload :SecureFixtureReader, "jobpayload/secure_fixture_reader"
18
20
  autoload :Fixture, "jobpayload/fixture"
19
21
  autoload :FixtureLoader, "jobpayload/fixture_loader"
20
22
  autoload :Checker, "jobpayload/checker"
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jobpayload
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - cottondesu
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-10-09 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activejob
@@ -34,6 +35,7 @@ description: |
34
35
  deterministic JSON fixtures and checks that the current application can still
35
36
  deserialize them (ActiveJob::Base.deserialize and ActiveJob::Arguments.deserialize)
36
37
  without ever performing the job.
38
+ email:
37
39
  executables:
38
40
  - jobpayload
39
41
  extensions: []
@@ -58,6 +60,8 @@ files:
58
60
  - lib/jobpayload/formatter/json.rb
59
61
  - lib/jobpayload/formatter/text.rb
60
62
  - lib/jobpayload/result.rb
63
+ - lib/jobpayload/secure_fixture_reader.rb
64
+ - lib/jobpayload/snapshot_checker.rb
61
65
  - lib/jobpayload/snapshotter.rb
62
66
  - lib/jobpayload/version.rb
63
67
  homepage: https://github.com/cottondesu/jobpayload
@@ -68,6 +72,7 @@ metadata:
68
72
  source_code_uri: https://github.com/cottondesu/jobpayload
69
73
  changelog_uri: https://github.com/cottondesu/jobpayload/blob/main/CHANGELOG.md
70
74
  rubygems_mfa_required: 'true'
75
+ post_install_message:
71
76
  rdoc_options: []
72
77
  require_paths:
73
78
  - lib
@@ -82,7 +87,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
82
87
  - !ruby/object:Gem::Version
83
88
  version: '0'
84
89
  requirements: []
85
- rubygems_version: 3.6.9
90
+ rubygems_version: 3.5.22
91
+ signing_key:
86
92
  specification_version: 4
87
93
  summary: Checks that previously serialized Active Job payloads still deserialize on
88
94
  new code.