flux-sim-engine 0.2.0__py3-none-any.whl
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.
- flux_engine/__init__.py +22 -0
- flux_engine/_links.py +55 -0
- flux_engine/_vendor/__init__.py +5 -0
- flux_engine/_vendor/flux_spec/NOTICE +19 -0
- flux_engine/_vendor/flux_spec/VENDOR-NOTES.md +104 -0
- flux_engine/_vendor/flux_spec/__init__.py +9 -0
- flux_engine/_vendor/flux_spec/bundle.py +410 -0
- flux_engine/_vendor/flux_spec/enforce.py +316 -0
- flux_engine/_vendor/flux_spec/schema/flux-enforcement-0.5.0.json +121 -0
- flux_engine/_vendor/flux_spec/schema/flux-manifest-0.5.0.json +153 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.2.0.json +465 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.3.0.json +876 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.0.json +1601 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.4.1.json +1668 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-0.5.0.json +1669 -0
- flux_engine/_vendor/flux_spec/schema/flux-schema-latest.json +1669 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.0.json +795 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.4.1.json +852 -0
- flux_engine/_vendor/flux_spec/schema/flux-ui-hints-0.5.0.json +852 -0
- flux_engine/_vendor/flux_spec/validate.py +537 -0
- flux_engine/_vendor/flux_spec/vectors/enforcement-vectors.json +935 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.3.json +5234 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.4.json +5437 -0
- flux_engine/_vendor/flux_spec/vendor/fluid/fluid-schema-0.7.5.json +5498 -0
- flux_engine/_vocabulary.py +141 -0
- flux_engine/accuracy/__init__.py +5 -0
- flux_engine/accuracy/_scoring/__init__.py +24 -0
- flux_engine/accuracy/accuracy_record.py +96 -0
- flux_engine/accuracy/policy.py +158 -0
- flux_engine/accuracy/records.py +264 -0
- flux_engine/accuracy/store.py +169 -0
- flux_engine/api/__init__.py +163 -0
- flux_engine/api/accuracy.py +209 -0
- flux_engine/api/agentic.py +134 -0
- flux_engine/api/calibration.py +125 -0
- flux_engine/api/claims.py +93 -0
- flux_engine/api/digests.py +73 -0
- flux_engine/api/emit.py +78 -0
- flux_engine/api/expressions.py +72 -0
- flux_engine/api/fidelity.py +110 -0
- flux_engine/api/lineage.py +78 -0
- flux_engine/api/stages.py +242 -0
- flux_engine/api/verdict.py +108 -0
- flux_engine/calibrate/__init__.py +5 -0
- flux_engine/calibrate/declared.py +253 -0
- flux_engine/calibrate/provenance.py +113 -0
- flux_engine/cli/__init__.py +2146 -0
- flux_engine/cli/__main__.py +17 -0
- flux_engine/cli/_documents.py +226 -0
- flux_engine/cli/_hints.py +118 -0
- flux_engine/cli/_init.py +152 -0
- flux_engine/cli/_progress.py +124 -0
- flux_engine/concordance/__init__.py +127 -0
- flux_engine/concordance/boundary/__init__.py +78 -0
- flux_engine/concordance/boundary/merkle.py +287 -0
- flux_engine/concordance/boundary/slice.py +429 -0
- flux_engine/concordance/boundary/statistic.py +561 -0
- flux_engine/concordance/boundary/store.py +334 -0
- flux_engine/concordance/boundary/vault.py +357 -0
- flux_engine/concordance/compiler.py +542 -0
- flux_engine/concordance/errors.py +189 -0
- flux_engine/concordance/gate.py +525 -0
- flux_engine/concordance/predicate.py +1217 -0
- flux_engine/concordance/record.py +554 -0
- flux_engine/concordance/verifier.py +583 -0
- flux_engine/core/__init__.py +5 -0
- flux_engine/core/artifact.py +99 -0
- flux_engine/core/canonical.py +369 -0
- flux_engine/core/clock.py +437 -0
- flux_engine/core/digest.py +288 -0
- flux_engine/core/errors.py +277 -0
- flux_engine/core/fixedpoint.py +929 -0
- flux_engine/core/lrm.py +158 -0
- flux_engine/core/rng.py +736 -0
- flux_engine/core/seedtree.py +891 -0
- flux_engine/core/trace.py +423 -0
- flux_engine/describe.py +207 -0
- flux_engine/engine.py +257 -0
- flux_engine/evidence/__init__.py +5 -0
- flux_engine/evidence/pack.py +411 -0
- flux_engine/evidence/report.py +243 -0
- flux_engine/expr/__init__.py +133 -0
- flux_engine/expr/ast.py +312 -0
- flux_engine/expr/attrs.py +386 -0
- flux_engine/expr/check.py +278 -0
- flux_engine/expr/compile.py +322 -0
- flux_engine/expr/errors.py +256 -0
- flux_engine/expr/lexer.py +297 -0
- flux_engine/expr/parser.py +313 -0
- flux_engine/expr/types.py +177 -0
- flux_engine/generator/__init__.py +97 -0
- flux_engine/generator/families.py +325 -0
- flux_engine/generator/paths.py +227 -0
- flux_engine/generator/rows.py +918 -0
- flux_engine/generator/statistics.py +524 -0
- flux_engine/governance/__init__.py +87 -0
- flux_engine/governance/audit.py +307 -0
- flux_engine/governance/gate.py +334 -0
- flux_engine/governance/skills.py +220 -0
- flux_engine/governance/sovereignty.py +251 -0
- flux_engine/lineage/__init__.py +5 -0
- flux_engine/lineage/emitter.py +398 -0
- flux_engine/py.typed +0 -0
- flux_engine/runtime/__init__.py +153 -0
- flux_engine/runtime/campaign.py +407 -0
- flux_engine/runtime/cast.py +283 -0
- flux_engine/runtime/detector.py +222 -0
- flux_engine/runtime/errors.py +58 -0
- flux_engine/runtime/experiment.py +311 -0
- flux_engine/runtime/journey.py +383 -0
- flux_engine/runtime/jurisdiction.py +72 -0
- flux_engine/runtime/payloads.py +124 -0
- flux_engine/runtime/signals.py +334 -0
- flux_engine/runtime/temperature.py +156 -0
- flux_engine/seam/__init__.py +5 -0
- flux_engine/seam/attest.py +290 -0
- flux_engine/seam/conform.py +448 -0
- flux_engine/seam/contract.py +605 -0
- flux_engine/seam/emit.py +488 -0
- flux_engine/seam/mapping.py +299 -0
- flux_engine/seam/resolve.py +1087 -0
- flux_engine/seam/sinks.py +157 -0
- flux_engine/spec/__init__.py +134 -0
- flux_engine/spec/bundle.py +25 -0
- flux_engine/spec/enforce.py +31 -0
- flux_engine/spec/validate.py +28 -0
- flux_engine/stages/__init__.py +30 -0
- flux_engine/stages/apply.py +447 -0
- flux_engine/stages/errors.py +315 -0
- flux_engine/stages/layout.py +292 -0
- flux_engine/stages/pipeline.py +600 -0
- flux_engine/stages/plan.py +241 -0
- flux_engine/stages/replay.py +83 -0
- flux_engine/stages/run.py +208 -0
- flux_engine/stages/ship.py +934 -0
- flux_engine/stages/validate.py +93 -0
- flux_engine/stages/verify.py +1129 -0
- flux_engine/starter/__init__.py +161 -0
- flux_engine/starter/bundle/detector.flux.yml +15 -0
- flux_engine/starter/bundle/journey.flux.yml +23 -0
- flux_engine/starter/bundle/module.flux.yml +16 -0
- flux_engine/starter/bundle/payment-recovery.fluid.yml +30 -0
- flux_engine/starter/bundle/persona.flux.yml +26 -0
- flux_engine/starter/bundle/signal.flux.yml +13 -0
- flux_engine/starter/bundle/simulation.flux.yml +29 -0
- flux_engine/starter/bundle/taxonomy.flux.yml +17 -0
- flux_engine/starter/bundle/treatment.flux.yml +13 -0
- flux_engine/starter/bundle/world.flux.yml +20 -0
- flux_engine/verticals/__init__.py +85 -0
- flux_engine/verticals/telco.py +89 -0
- flux_engine/world/__init__.py +89 -0
- flux_engine/world/derived.py +195 -0
- flux_engine/world/errors.py +41 -0
- flux_engine/world/families.py +260 -0
- flux_engine/world/population.py +602 -0
- flux_engine/world/tracing.py +66 -0
- flux_engine/world/values.py +121 -0
- flux_sim_engine-0.2.0.dist-info/METADATA +108 -0
- flux_sim_engine-0.2.0.dist-info/RECORD +165 -0
- flux_sim_engine-0.2.0.dist-info/WHEEL +5 -0
- flux_sim_engine-0.2.0.dist-info/entry_points.txt +2 -0
- flux_sim_engine-0.2.0.dist-info/licenses/LICENSE +202 -0
- flux_sim_engine-0.2.0.dist-info/licenses/NOTICE +10 -0
- flux_sim_engine-0.2.0.dist-info/licenses/flux_engine/_vendor/flux_spec/NOTICE +19 -0
- flux_sim_engine-0.2.0.dist-info/top_level.txt +1 -0
flux_engine/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Copyright 2026 Agentics Transformation Limited
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""FLUX Simulation Engine -- the runtime for FLUX bundles.
|
|
6
|
+
|
|
7
|
+
The sibling of the FLUID standard and CLI (``fluid``, ``data-product-forge``):
|
|
8
|
+
FLUX is the "wind tunnel" that runs a declarative simulation bundle and emits,
|
|
9
|
+
under a referenced FLUID contract, the exact rows that contract will see in
|
|
10
|
+
production.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from importlib import metadata as _metadata
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
__version__ = _metadata.version("flux-sim-engine")
|
|
19
|
+
except _metadata.PackageNotFoundError: # pragma: no cover - editable/dev tree, no metadata yet
|
|
20
|
+
__version__ = "0.2.0"
|
|
21
|
+
|
|
22
|
+
__all__ = ["__version__"]
|
flux_engine/_links.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Copyright 2026 Agentics Transformation Limited
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
"""Where the documentation lives, in one place.
|
|
6
|
+
|
|
7
|
+
Every URL the command line prints comes from here: the docs link at the foot of
|
|
8
|
+
``--help``, the page a failing command points at, the support link. That is what
|
|
9
|
+
keeps a printed URL stable. It is a function of a code or a command name and
|
|
10
|
+
nothing else, so a page can be moved by changing one constant, and a test
|
|
11
|
+
(``tests/docs/test_generated_docs.py``) holds that every URL this module builds
|
|
12
|
+
names a page that exists in ``docs/``.
|
|
13
|
+
|
|
14
|
+
The layout it relies on, relative to the root of these docs::
|
|
15
|
+
|
|
16
|
+
reference/cli/README.md every command <base>reference/cli/
|
|
17
|
+
reference/cli/<command>.md one command <base>reference/cli/<command>.html
|
|
18
|
+
reference/errors/README.md every FLX code <base>reference/errors/
|
|
19
|
+
reference/errors/FLX-nnnn.md one code <base>reference/errors/FLX-nnnn.html
|
|
20
|
+
|
|
21
|
+
The base is the address these docs are published at. They are written in this
|
|
22
|
+
repository and join the FLUX specification's site as its ``engine/`` folder. That
|
|
23
|
+
site is built with VuePress at ``base: '/flux/'``, which its config locks because
|
|
24
|
+
every published schema ``$id`` depends on it, so ``docs/engine/reference/errors/FLX-nnnn.md``
|
|
25
|
+
there is served as ``/flux/engine/reference/errors/FLX-nnnn.html``. Until the pages
|
|
26
|
+
are in the site they are not reachable: printing the address early is deliberate,
|
|
27
|
+
because the address is what has to stay stable.
|
|
28
|
+
|
|
29
|
+
**These addresses are public.** Every released version prints them, and a printed
|
|
30
|
+
address cannot be recalled. Moving a page means leaving something behind at the
|
|
31
|
+
old address. ``test_the_published_addresses_do_not_move`` in
|
|
32
|
+
``tests/docs/test_generated_docs.py`` holds the literal strings, so a change to
|
|
33
|
+
the base or to the layout above fails a test instead of passing quietly because
|
|
34
|
+
the page and the address moved together.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
__all__ = ["DOCS_URL", "ISSUES_URL", "command_url", "error_url"]
|
|
40
|
+
|
|
41
|
+
#: The folder of the published site these docs live in. Ends with a slash.
|
|
42
|
+
DOCS_URL = "https://agenticstiger.github.io/flux/engine/"
|
|
43
|
+
|
|
44
|
+
#: Where a person reports a problem with the engine.
|
|
45
|
+
ISSUES_URL = "https://github.com/Agenticstiger/flux-engine/issues"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def error_url(code: str) -> str:
|
|
49
|
+
"""The page that explains *code* (``FLX-nnnn``)."""
|
|
50
|
+
return f"{DOCS_URL}reference/errors/{code}.html"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def command_url(command: str = "") -> str:
|
|
54
|
+
"""The reference page of one top-level command, or the index when *command* is empty."""
|
|
55
|
+
return f"{DOCS_URL}reference/cli/{command}.html" if command else f"{DOCS_URL}reference/cli/"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
flux_spec (vendored copy)
|
|
2
|
+
Copyright 2026 The FLUX Project authors
|
|
3
|
+
|
|
4
|
+
This product includes software from the FLUX specification project
|
|
5
|
+
(https://github.com/Agenticstiger/flux), licensed under the Apache
|
|
6
|
+
License, Version 2.0. A copy of the License is available at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
See VENDOR-NOTES.md in this directory for the exact upstream commit this
|
|
11
|
+
copy was taken from and the minimal edits applied to make the validator,
|
|
12
|
+
enforcement gate, and bundler resolve their data paths from inside this
|
|
13
|
+
vendored package layout instead of a checked-out FLUX repository.
|
|
14
|
+
|
|
15
|
+
Unless required by applicable law or agreed to in writing, software
|
|
16
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
17
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
18
|
+
See the License for the specific language governing permissions and
|
|
19
|
+
limitations under the License.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Vendor notes — `flux_engine/_vendor/flux_spec/`
|
|
2
|
+
|
|
3
|
+
## Provenance
|
|
4
|
+
|
|
5
|
+
Copied verbatim (byte-for-byte, then edited as listed below) from
|
|
6
|
+
`Agenticstiger/flux`. The pinned commit below is the provenance; it is
|
|
7
|
+
reproducible from a clone of that repository anywhere.
|
|
8
|
+
|
|
9
|
+
**Upstream commit pinned:** `0181eeb21f35355c1f88bb8f06c8335493d94611`
|
|
10
|
+
|
|
11
|
+
Retrieved via `git show <commit>:<path>` rather than the working tree,
|
|
12
|
+
because the FLUX checkout had uncommitted, in-flight changes at copy time
|
|
13
|
+
(a `feat/flux-spec-package` branch already mid-refactor into a `flux_spec/`
|
|
14
|
+
package with `scripts/*.py` becoming compatibility shims — exactly the
|
|
15
|
+
public-distribution work owner decision 2 describes for a later
|
|
16
|
+
increment, W1). Pinning to the last commit keeps this vendor copy
|
|
17
|
+
reproducible and independent of that unmerged, unstable work.
|
|
18
|
+
|
|
19
|
+
Files copied, each verified byte-identical to the pinned blob before any
|
|
20
|
+
edit was applied:
|
|
21
|
+
|
|
22
|
+
| Vendored path | Upstream path (at the pinned commit) |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `validate.py` | `scripts/validate.py` |
|
|
25
|
+
| `enforce.py` | `scripts/enforce.py` |
|
|
26
|
+
| `bundle.py` | `scripts/bundle.py` |
|
|
27
|
+
| `schema/*.json` (11 files) | `schema/*.json` |
|
|
28
|
+
| `vendor/fluid/*.json` (3 files) | `vendor/fluid/*.json` |
|
|
29
|
+
| `vectors/enforcement-vectors.json` | `tests/enforcement-vectors.json` |
|
|
30
|
+
|
|
31
|
+
`tests/test_regression.py` was read for context (not vendored — flux_engine
|
|
32
|
+
has its own test suite; `test_spec_shim.py` exercises this vendored copy
|
|
33
|
+
directly instead of re-running the upstream regression file).
|
|
34
|
+
|
|
35
|
+
## Lines changed (minimal edits, path resolution only)
|
|
36
|
+
|
|
37
|
+
No logic, control flow, or behavior was changed. Every edit is a
|
|
38
|
+
`Path(__file__)`-relative constant, changed because this copy lives
|
|
39
|
+
directly inside the vendored package directory instead of two levels
|
|
40
|
+
below a repository root under `scripts/`.
|
|
41
|
+
|
|
42
|
+
### `validate.py`
|
|
43
|
+
|
|
44
|
+
```diff
|
|
45
|
+
-REPO = Path(__file__).resolve().parent.parent
|
|
46
|
+
+REPO = Path(__file__).resolve().parent
|
|
47
|
+
FLUX_SCHEMA_PATH = REPO / "schema" / "flux-schema-latest.json"
|
|
48
|
+
FLUID_VENDOR_DIR = REPO / "vendor" / "fluid"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`FLUX_SCHEMA_PATH` and `FLUID_VENDOR_DIR` are unchanged — they already
|
|
52
|
+
resolve correctly once `REPO` points at this directory (which now itself
|
|
53
|
+
contains `schema/` and `vendor/fluid/`).
|
|
54
|
+
|
|
55
|
+
Left intentionally unfixed (inert in flux_engine's usage): `main()`'s
|
|
56
|
+
no-argument branch does `(REPO / "examples").iterdir()` to discover bundle
|
|
57
|
+
directories for the standalone `python3 scripts/validate.py` CLI. This
|
|
58
|
+
vendored copy has no `examples/` directory and `flux_engine.spec.validate`
|
|
59
|
+
never calls `main()` — only `Bundle`, `canonical_digest`,
|
|
60
|
+
`load_yaml_docs`, and `semver_satisfies` are imported by the shim
|
|
61
|
+
(`flux_engine/spec/validate.py`). If a future increment wants the
|
|
62
|
+
standalone CLI to work from this vendored copy, that branch needs its own
|
|
63
|
+
fix at that time.
|
|
64
|
+
|
|
65
|
+
### `enforce.py`
|
|
66
|
+
|
|
67
|
+
```diff
|
|
68
|
+
-REPO = Path(__file__).resolve().parent.parent
|
|
69
|
+
-DEFAULT_VECTORS = REPO / "tests" / "enforcement-vectors.json"
|
|
70
|
+
+REPO = Path(__file__).resolve().parent
|
|
71
|
+
+DEFAULT_VECTORS = REPO / "vectors" / "enforcement-vectors.json"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The vectors file is vendored under `vectors/` (not `tests/`) per the task
|
|
75
|
+
spec, so the directory segment changed along with the `.parent` count.
|
|
76
|
+
|
|
77
|
+
### `bundle.py`
|
|
78
|
+
|
|
79
|
+
```diff
|
|
80
|
+
-REPO = Path(__file__).resolve().parent.parent
|
|
81
|
+
-sys.path.insert(0, str(REPO / "scripts"))
|
|
82
|
+
-from validate import Bundle, load_yaml_docs # noqa: E402
|
|
83
|
+
+REPO = Path(__file__).resolve().parent
|
|
84
|
+
+from .validate import Bundle, load_yaml_docs # noqa: E402
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`validate.py` is now a sibling module inside the same Python package
|
|
88
|
+
(`flux_engine._vendor.flux_spec`) rather than a same-directory *script*
|
|
89
|
+
next to `bundle.py` on `sys.path`, so the `sys.path.insert` + bare
|
|
90
|
+
top-level `import validate` becomes an ordinary package-relative import.
|
|
91
|
+
`import sys` was dropped from the top-of-file import block because the
|
|
92
|
+
removed `sys.path.insert` call was its only use in the module.
|
|
93
|
+
|
|
94
|
+
`SCHEMA_PATH`, `MANIFEST_SCHEMA_PATH`, and the `-o/--out` argparse default
|
|
95
|
+
(`REPO / "dist"`) are unchanged — all already resolve correctly under the
|
|
96
|
+
one-level-shallower `REPO`.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
Apache License, Version 2.0. See `NOTICE` in this directory.
|
|
101
|
+
|
|
102
|
+
## Provenance check (2026-09-06)
|
|
103
|
+
|
|
104
|
+
All vendored JSON files are byte-identical to `origin/main` (`35d979f`) of Agenticstiger/flux; the three Python modules are identical to `origin/main` except for the path edits listed above.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Vendored fallback copy of the ``flux_spec`` distribution (Apache-2.0).
|
|
2
|
+
|
|
3
|
+
See ``NOTICE`` for attribution and ``VENDOR-NOTES.md`` for the upstream
|
|
4
|
+
commit this copy was taken from and every line changed to make it resolve
|
|
5
|
+
its data paths from inside this package instead of a checked-out FLUX
|
|
6
|
+
repository. ``flux_engine.spec`` is the only supported entry point into
|
|
7
|
+
this package from the rest of flux_engine — nothing else should import
|
|
8
|
+
``flux_engine._vendor.flux_spec`` directly except to resolve a data path.
|
|
9
|
+
"""
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""FLUX evidence bundles (RFC-09).
|
|
3
|
+
|
|
4
|
+
Turns a validated bundle directory into a portable, verifiable artifact:
|
|
5
|
+
|
|
6
|
+
python3 scripts/bundle.py pack examples/telco-payment-recovery -o dist/
|
|
7
|
+
python3 scripts/bundle.py verify dist/telco-payment-recovery.flux.tgz
|
|
8
|
+
|
|
9
|
+
`pack` validates the bundle offline, writes `flux-manifest.json` (per-file
|
|
10
|
+
sha256, an RFC 6962 Merkle root, and an attestation of what the toolchain
|
|
11
|
+
could prove), emits a **deterministic** gzip archive, and writes an in-toto
|
|
12
|
+
Statement v1 for supply-chain tooling.
|
|
13
|
+
|
|
14
|
+
Determinism follows the reproducible-builds.org recipe: entries sorted by
|
|
15
|
+
name, mtime zeroed in both the tar entries and the gzip header, ownership
|
|
16
|
+
normalised to 0/0 with empty names, fixed mode, GNU format (PAX extended
|
|
17
|
+
headers can smuggle atime/ctime). Same inputs, same bytes — so two parties
|
|
18
|
+
can compare bundles by digest alone.
|
|
19
|
+
|
|
20
|
+
`verify` re-derives every digest and the Merkle root, re-validates the
|
|
21
|
+
documents, and checks the attestation against reality. It needs nothing from
|
|
22
|
+
the author: no engine, no network, no trust in the packer.
|
|
23
|
+
|
|
24
|
+
Signing is deliberately transport-agnostic and detached: sign the manifest
|
|
25
|
+
bytes with whatever your organisation already uses —
|
|
26
|
+
|
|
27
|
+
ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n flux flux-manifest.json
|
|
28
|
+
cosign sign-blob --key cosign.key flux-manifest.json
|
|
29
|
+
openssl dgst -sha256 -sign key.pem -out flux-manifest.sig flux-manifest.json
|
|
30
|
+
|
|
31
|
+
— and ship the signature alongside. Because the manifest commits to the
|
|
32
|
+
Merkle root, a signature over the manifest covers every byte of the bundle.
|
|
33
|
+
|
|
34
|
+
Profiles: an offline bundler may attest `core` (validator green) or
|
|
35
|
+
`enterprise` (validator green plus a Playback scorecard). It never attests
|
|
36
|
+
`runtime` — Level 3 requires a live engine, so claiming it from a packer
|
|
37
|
+
would be a lie.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import gzip
|
|
44
|
+
import hashlib
|
|
45
|
+
import io
|
|
46
|
+
import json
|
|
47
|
+
import shutil
|
|
48
|
+
import tarfile
|
|
49
|
+
import tempfile
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
# VENDOR EDIT (see VENDOR-NOTES.md): one level shallower (see validate.py),
|
|
53
|
+
# and the sibling-module sys.path shim is replaced by a package-relative
|
|
54
|
+
# import now that validate.py is a sibling module inside the same package
|
|
55
|
+
# instead of a same-directory script. ``sys`` was only imported for the
|
|
56
|
+
# removed ``sys.path.insert`` call, so the import is dropped too.
|
|
57
|
+
REPO = Path(__file__).resolve().parent
|
|
58
|
+
from .validate import Bundle, load_yaml_docs # noqa: E402
|
|
59
|
+
|
|
60
|
+
from jsonschema import Draft202012Validator, FormatChecker # noqa: E402
|
|
61
|
+
from jsonschema.exceptions import ValidationError # noqa: E402
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Rejected(Exception):
|
|
65
|
+
"""Internal: stop verifying, the problem list already says why."""
|
|
66
|
+
|
|
67
|
+
SCHEMA_PATH = REPO / "schema" / "flux-schema-latest.json"
|
|
68
|
+
MANIFEST_SCHEMA_PATH = REPO / "schema" / "flux-manifest-0.5.0.json"
|
|
69
|
+
MANIFEST_NAME = "flux-manifest.json"
|
|
70
|
+
STATEMENT_SUFFIX = ".attestation.json"
|
|
71
|
+
PREDICATE_TYPE = "https://agenticstiger.github.io/flux/attestation/v1"
|
|
72
|
+
GENERATOR = "flux bundle 0.5.0"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sha256_file(path: Path) -> str:
|
|
76
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def merkle_root(files: dict[str, str]) -> str:
|
|
80
|
+
"""Root over sorted (path, digest) pairs.
|
|
81
|
+
|
|
82
|
+
Domain separation follows RFC 6962 (Certificate Transparency): leaves are
|
|
83
|
+
hashed with a 0x00 prefix and interior nodes with 0x01, so an interior
|
|
84
|
+
hash can never be replayed as a leaf (the Merkle second-preimage attack).
|
|
85
|
+
An odd node is promoted unchanged.
|
|
86
|
+
|
|
87
|
+
One deliberate divergence from RFC 6962: the leaf commits to
|
|
88
|
+
`path\\0digest`, not to the file bytes alone. A manifest must bind *which*
|
|
89
|
+
file has which digest — without the path in the leaf, swapping the
|
|
90
|
+
contents of two manifested files would leave the root unchanged.
|
|
91
|
+
|
|
92
|
+
Paths are sorted by Unicode code point (Python's default), which is
|
|
93
|
+
locale-independent — unlike shell `sort`, a known reproducibility trap.
|
|
94
|
+
"""
|
|
95
|
+
LEAF, NODE = b"\x00", b"\x01"
|
|
96
|
+
level = [
|
|
97
|
+
hashlib.sha256(LEAF + path.encode() + b"\x00" + bytes.fromhex(digest.split(":", 1)[1])).digest()
|
|
98
|
+
for path, digest in sorted(files.items())
|
|
99
|
+
]
|
|
100
|
+
if not level:
|
|
101
|
+
return "sha256:" + hashlib.sha256(LEAF).hexdigest()
|
|
102
|
+
while len(level) > 1:
|
|
103
|
+
level = [
|
|
104
|
+
hashlib.sha256(NODE + level[i] + level[i + 1]).digest() if i + 1 < len(level) else level[i]
|
|
105
|
+
for i in range(0, len(level), 2)
|
|
106
|
+
]
|
|
107
|
+
return "sha256:" + level[0].hex()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def intoto_statement(archive: Path, manifest: dict) -> dict:
|
|
111
|
+
"""The attestation as an in-toto Statement v1 (in-toto.io/Statement/v1).
|
|
112
|
+
|
|
113
|
+
The manifest is the bundle's *internal* integrity structure; this is the
|
|
114
|
+
*interop* artifact. Shaping it as an in-toto Statement means existing
|
|
115
|
+
supply-chain tooling — cosign, in-toto-verify, anything that speaks DSSE —
|
|
116
|
+
can carry, sign and verify FLUX evidence without knowing what FLUX is:
|
|
117
|
+
|
|
118
|
+
cosign attest-blob --predicate flux-attestation.json \\
|
|
119
|
+
--type https://agenticstiger.github.io/flux/attestation/v1 <archive>
|
|
120
|
+
|
|
121
|
+
Note in-toto digests are bare hex keyed by algorithm, not the `sha256:`
|
|
122
|
+
prefixed form used inside the FLUX manifest.
|
|
123
|
+
"""
|
|
124
|
+
return {
|
|
125
|
+
"_type": "https://in-toto.io/Statement/v1",
|
|
126
|
+
"subject": [{"name": archive.name, "digest": {"sha256": sha256_file(archive)}}],
|
|
127
|
+
"predicateType": PREDICATE_TYPE,
|
|
128
|
+
"predicate": {
|
|
129
|
+
"schemaId": manifest["schemaId"],
|
|
130
|
+
"fluxVersions": manifest["fluxVersions"],
|
|
131
|
+
"merkleRoot": manifest["merkleRoot"],
|
|
132
|
+
"fileCount": len(manifest["files"]),
|
|
133
|
+
**manifest["attestation"],
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def bundle_files(directory: Path, skip_signatures: bool = True) -> dict[str, str]:
|
|
139
|
+
"""Digest every file in the bundle.
|
|
140
|
+
|
|
141
|
+
The manifest is always excluded — it cannot contain its own digest.
|
|
142
|
+
Detached signatures are excluded while *packing* (they belong beside the
|
|
143
|
+
archive, not inside it), but never while *verifying*: an unmanifested
|
|
144
|
+
file must be reported no matter what it is called, or `evil.sig` becomes
|
|
145
|
+
a place to smuggle content past the digest check.
|
|
146
|
+
"""
|
|
147
|
+
out = {}
|
|
148
|
+
for path in sorted(p for p in directory.rglob("*") if p.is_file()):
|
|
149
|
+
rel = path.relative_to(directory).as_posix()
|
|
150
|
+
if rel == MANIFEST_NAME or (skip_signatures and rel.endswith(".sig")):
|
|
151
|
+
continue
|
|
152
|
+
out[rel] = "sha256:" + sha256_file(path)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def collect_attestation(directory: Path, validator) -> tuple[dict, list[str]]:
|
|
157
|
+
"""Validate the bundle and describe what can honestly be attested."""
|
|
158
|
+
b = Bundle(directory, validator)
|
|
159
|
+
errors = b.run()
|
|
160
|
+
if errors:
|
|
161
|
+
return {}, errors
|
|
162
|
+
|
|
163
|
+
scorecards, seam, versions = [], [], set()
|
|
164
|
+
for doc_id, doc in b.flux_docs.items():
|
|
165
|
+
versions.add(doc.get("fluxVersion"))
|
|
166
|
+
spec = doc.get("spec", {})
|
|
167
|
+
if doc.get("kind") == "Playback" and isinstance(spec.get("scorecard"), dict):
|
|
168
|
+
sc = spec["scorecard"]
|
|
169
|
+
scorecards.append({"playbackId": doc_id, "grade": sc["grade"], "credibility": sc["credibility"]})
|
|
170
|
+
if doc.get("kind") == "Simulation":
|
|
171
|
+
for emit in spec.get("emits", []):
|
|
172
|
+
entry = {
|
|
173
|
+
"simulationId": doc_id,
|
|
174
|
+
"productRef": emit["productRef"],
|
|
175
|
+
"exposeId": emit["exposeId"],
|
|
176
|
+
"fluidVersion": emit["fluidVersion"],
|
|
177
|
+
}
|
|
178
|
+
digest = (emit.get("provenance") or {}).get("contractDigest")
|
|
179
|
+
if digest:
|
|
180
|
+
entry["contractDigest"] = digest
|
|
181
|
+
seam.append(entry)
|
|
182
|
+
|
|
183
|
+
attestation = {
|
|
184
|
+
"profile": "enterprise" if scorecards else "core",
|
|
185
|
+
"validator": {"status": "green", "documents": len(b.flux_docs)},
|
|
186
|
+
}
|
|
187
|
+
if scorecards:
|
|
188
|
+
attestation["scorecards"] = sorted(scorecards, key=lambda s: s["playbackId"])
|
|
189
|
+
if seam:
|
|
190
|
+
attestation["seam"] = seam
|
|
191
|
+
return {"attestation": attestation, "fluxVersions": sorted(v for v in versions if v)}, []
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build_manifest(directory: Path, validator) -> tuple[dict, list[str]]:
|
|
195
|
+
extra, errors = collect_attestation(directory, validator)
|
|
196
|
+
if errors:
|
|
197
|
+
return {}, errors
|
|
198
|
+
files = bundle_files(directory)
|
|
199
|
+
schema_id = json.loads(SCHEMA_PATH.read_text())["$id"]
|
|
200
|
+
manifest = {
|
|
201
|
+
"manifestVersion": 1,
|
|
202
|
+
"generator": GENERATOR,
|
|
203
|
+
"schemaId": schema_id,
|
|
204
|
+
"fluxVersions": extra["fluxVersions"],
|
|
205
|
+
"files": files,
|
|
206
|
+
"merkleRoot": merkle_root(files),
|
|
207
|
+
"attestation": extra["attestation"],
|
|
208
|
+
}
|
|
209
|
+
return manifest, []
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _deterministic_info(name: str, size: int) -> tarfile.TarInfo:
|
|
213
|
+
info = tarfile.TarInfo(name)
|
|
214
|
+
info.size = size
|
|
215
|
+
info.mtime = 0 # zeroed: same inputs -> same bytes
|
|
216
|
+
info.mode = 0o644
|
|
217
|
+
info.uid = info.gid = 0
|
|
218
|
+
info.uname = info.gname = ""
|
|
219
|
+
return info
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def pack(directory: Path, out_dir: Path, validator) -> int:
|
|
223
|
+
directory = directory.resolve() # `pack .` must not become a nameless archive
|
|
224
|
+
if not directory.name:
|
|
225
|
+
print(f"[FAIL] {directory}: cannot determine a bundle name")
|
|
226
|
+
return 1
|
|
227
|
+
manifest, errors = build_manifest(directory, validator)
|
|
228
|
+
if errors:
|
|
229
|
+
print(f"[FAIL] {directory}: bundle does not validate — refusing to pack")
|
|
230
|
+
for e in errors[:10]:
|
|
231
|
+
print(f" {e}")
|
|
232
|
+
return 1
|
|
233
|
+
Draft202012Validator(json.loads(MANIFEST_SCHEMA_PATH.read_text())).validate(manifest)
|
|
234
|
+
|
|
235
|
+
(directory / MANIFEST_NAME).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
|
236
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
archive = out_dir / f"{directory.name}.flux.tgz"
|
|
238
|
+
|
|
239
|
+
payload = dict(manifest["files"])
|
|
240
|
+
payload[MANIFEST_NAME] = "sha256:" + sha256_file(directory / MANIFEST_NAME)
|
|
241
|
+
raw = io.BytesIO()
|
|
242
|
+
# GNU format, not PAX: reproducible-builds.org warns PAX extended headers
|
|
243
|
+
# can carry atime/ctime (and PIDs under POSIXLY_CORRECT).
|
|
244
|
+
with tarfile.open(fileobj=raw, mode="w", format=tarfile.GNU_FORMAT) as tar:
|
|
245
|
+
for rel in sorted(payload):
|
|
246
|
+
data = (directory / rel).read_bytes()
|
|
247
|
+
tar.addfile(_deterministic_info(f"{directory.name}/{rel}", len(data)), io.BytesIO(data))
|
|
248
|
+
# gzip separately with mtime=0 — the gzip header carries its own timestamp,
|
|
249
|
+
# which would otherwise make every repack a different file.
|
|
250
|
+
with open(archive, "wb") as fh:
|
|
251
|
+
with gzip.GzipFile(filename="", mode="wb", compresslevel=9, fileobj=fh, mtime=0) as gz:
|
|
252
|
+
gz.write(raw.getvalue())
|
|
253
|
+
|
|
254
|
+
statement = intoto_statement(archive, manifest)
|
|
255
|
+
statement_path = out_dir / f"{directory.name}{STATEMENT_SUFFIX}"
|
|
256
|
+
statement_path.write_text(json.dumps(statement, indent=2, sort_keys=True) + "\n")
|
|
257
|
+
|
|
258
|
+
print(f"[ok] packed {archive}")
|
|
259
|
+
print(f" profile={manifest['attestation']['profile']} "
|
|
260
|
+
f"documents={manifest['attestation']['validator']['documents']} "
|
|
261
|
+
f"files={len(manifest['files'])}")
|
|
262
|
+
print(f" merkleRoot={manifest['merkleRoot']}")
|
|
263
|
+
print(f" archive sha256:{sha256_file(archive)}")
|
|
264
|
+
print(f" in-toto statement: {statement_path}")
|
|
265
|
+
print(f" sign it: ssh-keygen -Y sign -f <key> -n flux {directory / MANIFEST_NAME}")
|
|
266
|
+
print(f" or: cosign attest-blob --predicate {statement_path} --type {PREDICATE_TYPE} {archive}")
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
MAX_BUNDLE_BYTES = 256 * 1024 * 1024 # declared, uncompressed, across all members
|
|
271
|
+
MAX_MEMBERS = 10_000
|
|
272
|
+
COPY_CHUNK = 1 << 20
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _extract_safely(tar: tarfile.TarFile, root: Path, problems: list[str]) -> Path | None:
|
|
276
|
+
"""Materialise a verified-safe subset of the archive under `root`.
|
|
277
|
+
|
|
278
|
+
Everything hostile is rejected *before* anything is written: attacker-declared
|
|
279
|
+
sizes are budgeted first (gzip compresses zeros ~1000:1, so a 500 KB archive
|
|
280
|
+
can otherwise claim gigabytes), only regular files are extracted, and every
|
|
281
|
+
member must live under one shared top-level directory — a member outside it
|
|
282
|
+
would be extracted but never digested, which is exactly how undeclared
|
|
283
|
+
content sneaks past a manifest.
|
|
284
|
+
"""
|
|
285
|
+
members = tar.getmembers()
|
|
286
|
+
if len(members) > MAX_MEMBERS:
|
|
287
|
+
problems.append(f"archive declares {len(members)} members, above the {MAX_MEMBERS} cap")
|
|
288
|
+
return None
|
|
289
|
+
declared = sum(m.size for m in members if m.isfile())
|
|
290
|
+
if declared > MAX_BUNDLE_BYTES:
|
|
291
|
+
problems.append(f"archive declares {declared} uncompressed bytes, above the {MAX_BUNDLE_BYTES} cap")
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
tops = set()
|
|
295
|
+
for m in members:
|
|
296
|
+
name = Path(m.name)
|
|
297
|
+
if name.is_absolute() or ".." in name.parts or not name.parts:
|
|
298
|
+
problems.append(f"unsafe archive path: {m.name}")
|
|
299
|
+
return None
|
|
300
|
+
tops.add(name.parts[0])
|
|
301
|
+
if len(tops) != 1:
|
|
302
|
+
problems.append(f"expected exactly one bundle directory, found {sorted(tops) or 'none'}")
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
for m in members:
|
|
306
|
+
if m.isdir():
|
|
307
|
+
continue
|
|
308
|
+
if not m.isfile():
|
|
309
|
+
problems.append(f"unsupported archive entry ({'symlink' if m.issym() or m.islnk() else 'special'}): {m.name}")
|
|
310
|
+
return None
|
|
311
|
+
if len(Path(m.name).parts) < 2:
|
|
312
|
+
problems.append(f"entry outside the bundle directory: {m.name}")
|
|
313
|
+
return None
|
|
314
|
+
target = root / m.name
|
|
315
|
+
if target.exists():
|
|
316
|
+
problems.append(f"duplicate archive entry: {m.name}")
|
|
317
|
+
return None
|
|
318
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
source = tar.extractfile(m)
|
|
320
|
+
if source is None:
|
|
321
|
+
problems.append(f"unreadable archive entry: {m.name}")
|
|
322
|
+
return None
|
|
323
|
+
with open(target, "wb") as fh:
|
|
324
|
+
shutil.copyfileobj(source, fh, COPY_CHUNK)
|
|
325
|
+
return root / tops.pop()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def verify(archive: Path, validator) -> int:
|
|
329
|
+
"""Verify an archive offline. Always reports; never raises."""
|
|
330
|
+
problems: list[str] = []
|
|
331
|
+
manifest = {}
|
|
332
|
+
try:
|
|
333
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
334
|
+
root = Path(tmp)
|
|
335
|
+
with tarfile.open(archive, "r:gz") as tar:
|
|
336
|
+
directory = _extract_safely(tar, root, problems)
|
|
337
|
+
if directory is None:
|
|
338
|
+
raise _Rejected()
|
|
339
|
+
|
|
340
|
+
manifest_path = directory / MANIFEST_NAME
|
|
341
|
+
if not manifest_path.exists():
|
|
342
|
+
problems.append(f"no {MANIFEST_NAME}")
|
|
343
|
+
raise _Rejected()
|
|
344
|
+
manifest = json.loads(manifest_path.read_text())
|
|
345
|
+
Draft202012Validator(json.loads(MANIFEST_SCHEMA_PATH.read_text())).validate(manifest)
|
|
346
|
+
|
|
347
|
+
actual = bundle_files(directory, skip_signatures=False)
|
|
348
|
+
for rel, digest in manifest["files"].items():
|
|
349
|
+
if rel not in actual:
|
|
350
|
+
problems.append(f"missing file: {rel}")
|
|
351
|
+
elif actual[rel] != digest:
|
|
352
|
+
problems.append(f"digest mismatch: {rel}")
|
|
353
|
+
for rel in actual:
|
|
354
|
+
if rel not in manifest["files"]:
|
|
355
|
+
problems.append(f"unmanifested file: {rel}")
|
|
356
|
+
if merkle_root(manifest["files"]) != manifest["merkleRoot"]:
|
|
357
|
+
problems.append("merkleRoot does not match the manifest file list")
|
|
358
|
+
|
|
359
|
+
# Everything the manifest asserts is re-derived, never trusted.
|
|
360
|
+
expected_schema_id = json.loads(SCHEMA_PATH.read_text())["$id"]
|
|
361
|
+
if manifest["schemaId"] != expected_schema_id:
|
|
362
|
+
problems.append(f"schemaId {manifest['schemaId']} is not this toolchain's schema ({expected_schema_id})")
|
|
363
|
+
rebuilt, errors = collect_attestation(directory, validator)
|
|
364
|
+
if errors:
|
|
365
|
+
problems.append(f"bundle no longer validates ({len(errors)} errors, first: {errors[0]})")
|
|
366
|
+
else:
|
|
367
|
+
if rebuilt["attestation"] != manifest["attestation"]:
|
|
368
|
+
problems.append("attestation does not match a fresh validation of the bundle")
|
|
369
|
+
if rebuilt["fluxVersions"] != manifest["fluxVersions"]:
|
|
370
|
+
problems.append(f"fluxVersions {manifest['fluxVersions']} does not match the documents ({rebuilt['fluxVersions']})")
|
|
371
|
+
except _Rejected:
|
|
372
|
+
pass
|
|
373
|
+
except tarfile.ReadError as exc:
|
|
374
|
+
problems.append(f"not a readable gzip archive: {exc}")
|
|
375
|
+
except json.JSONDecodeError as exc:
|
|
376
|
+
problems.append(f"{MANIFEST_NAME} is not valid JSON: {exc}")
|
|
377
|
+
except ValidationError as exc:
|
|
378
|
+
problems.append(f"{MANIFEST_NAME} violates the manifest schema: {exc.message}")
|
|
379
|
+
except OSError as exc:
|
|
380
|
+
problems.append(f"cannot read archive: {exc}")
|
|
381
|
+
|
|
382
|
+
if problems:
|
|
383
|
+
print(f"[FAIL] {archive}")
|
|
384
|
+
for p in problems[:10]:
|
|
385
|
+
print(f" {p}")
|
|
386
|
+
return 1
|
|
387
|
+
print(f"[ok] {archive} — {len(manifest['files'])} files, merkle root verified, "
|
|
388
|
+
f"attestation reproduced (profile={manifest['attestation']['profile']})")
|
|
389
|
+
print(f" verify the signature separately, e.g. ssh-keygen -Y verify ... < {MANIFEST_NAME}")
|
|
390
|
+
return 0
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def main(argv=None) -> int:
|
|
394
|
+
ap = argparse.ArgumentParser(description="FLUX evidence bundles (RFC-09)")
|
|
395
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
396
|
+
p = sub.add_parser("pack", help="validate, manifest and archive a bundle")
|
|
397
|
+
p.add_argument("directory", type=Path)
|
|
398
|
+
p.add_argument("-o", "--out", type=Path, default=REPO / "dist")
|
|
399
|
+
v = sub.add_parser("verify", help="verify an archive offline")
|
|
400
|
+
v.add_argument("archive", type=Path)
|
|
401
|
+
args = ap.parse_args(argv)
|
|
402
|
+
|
|
403
|
+
validator = Draft202012Validator(json.loads(SCHEMA_PATH.read_text()), format_checker=FormatChecker())
|
|
404
|
+
if args.cmd == "pack":
|
|
405
|
+
return pack(args.directory, args.out, validator)
|
|
406
|
+
return verify(args.archive, validator)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__":
|
|
410
|
+
raise SystemExit(main())
|