From bf9dce206d47b470989b9195c72cdfac9805e1bd Mon Sep 17 00:00:00 2001 From: Speculator55005 <50082482+fas89@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:31:37 +0100 Subject: [PATCH 1/3] fix(bundle): --out left out means what --help says, fragments/ or not With a fragments/ directory beside the contract, `fluid bundle` without --out wrote contract.bundled.fluid.yaml instead of stdout: a pipe received nothing, --format json put JSON under the .yaml name, and --format tgz put the gzip tarball there too, which the next stage read as YAML ("'utf-8' codec can't decode byte 0x8b"). `fluid ship` bundles that way on every run. The auto-detect is gone: yaml and json go to stdout, a tgz to .fluid.bundle.tgz, the same for every contract (kustomize build and helm template print to stdout the same way). --help now says both defaults. Founder decision 2026-10-29: follow --help, rather than keep an auto file. --- fluid_build/cli/bundle.py | 14 +++++----- tests/forge/test_bundle_core.py | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/fluid_build/cli/bundle.py b/fluid_build/cli/bundle.py index 44dd0859..64122e39 100644 --- a/fluid_build/cli/bundle.py +++ b/fluid_build/cli/bundle.py @@ -83,7 +83,10 @@ def register(subparsers: argparse._SubParsersAction) -> None: "--out", "-o", default="-", - help="Output path (default: '-' for stdout)", + help=( + "Output path (default: '-', stdout for yaml and json; for tgz, " + "/.fluid.bundle.tgz)" + ), ) p.add_argument( "--env", @@ -353,12 +356,11 @@ def run(args: argparse.Namespace, logger: logging.Logger) -> int: _lgr.addHandler(_stderr_handler) _lgr.propagate = False - # Auto-detect output: if a fragments/ directory exists alongside the - # contract, the user almost certainly wants a bundled file on disk - # rather than stdout output. + # ``--out`` left out means the same for every contract, fragments/ beside it or not: + # yaml and json go to stdout, a tgz to ``.fluid.bundle.tgz``. (A + # fragment layout used to get ``contract.bundled.fluid.yaml`` instead, a tgz too, + # so a pipe received nothing and a later stage read gzip bytes as YAML.) contract_dir = Path(contract_path).parent - if out == "-" and (contract_dir / "fragments").is_dir(): - out = str(contract_dir / "contract.bundled.fluid.yaml") fmt = _infer_format(out, args.format) diff --git a/tests/forge/test_bundle_core.py b/tests/forge/test_bundle_core.py index 44d2ef15..df5f9cbf 100644 --- a/tests/forge/test_bundle_core.py +++ b/tests/forge/test_bundle_core.py @@ -30,6 +30,7 @@ from pathlib import Path import pytest +import yaml from fluid_build.forge.core.bundle import ( MANIFEST_VERSION, @@ -515,6 +516,50 @@ def test_default_filename_slugifies_nonfilesafe_ids(self, tmp_path): assert rc == 0 assert (tmp_path / "My-Product-v2.fluid.bundle.tgz").exists() + def _fragment_layout(self, tmp_path: Path) -> Path: + """A contract with ``fragments/`` beside it, as ``fluid split`` leaves one.""" + expose = tmp_path / "fragments" / "exposes" / "orders.yaml" + expose.parent.mkdir(parents=True) + expose.write_text("exposeId: orders\nkind: table\n") + return self._write_contract( + tmp_path, + "fluidVersion: '0.7.2'\nkind: DataProduct\nid: customer-360\n" + "exposes:\n - $ref: ./fragments/exposes/orders.yaml\n", + ) + + def test_a_fragment_layout_gets_the_same_tgz_default(self, tmp_path): + """Not ``contract.bundled.fluid.yaml``: a tarball under a ``.yaml`` name was read as + YAML by the next stage (``'utf-8' codec can't decode byte 0x8b``).""" + import argparse + import logging + + from fluid_build.cli.bundle import run + + contract_path = self._fragment_layout(tmp_path) + args = argparse.Namespace(contract=str(contract_path), out="-", env=None, format="tgz") + + assert run(args, logging.getLogger("test")) == 0 + assert (tmp_path / "customer-360.fluid.bundle.tgz").exists() + assert not (tmp_path / "contract.bundled.fluid.yaml").exists() + + @pytest.mark.parametrize("fmt", ["yaml", "json"]) + def test_a_fragment_layout_prints_the_bundle_to_stdout(self, tmp_path, capsys, fmt): + """``fluid bundle contract.fluid.yaml | ...`` gets the composed contract, as the + help says, whether or not fragments/ sits beside it.""" + import argparse + import logging + + from fluid_build.cli.bundle import run + + contract_path = self._fragment_layout(tmp_path) + args = argparse.Namespace(contract=str(contract_path), out="-", env=None, format=fmt) + + assert run(args, logging.getLogger("test")) == 0 + document = yaml.safe_load(capsys.readouterr().out) # JSON is YAML too + assert document["id"] == "customer-360" + assert document["exposes"][0]["exposeId"] == "orders" + assert not (tmp_path / "contract.bundled.fluid.yaml").exists() + def test_explicit_out_overrides_default(self, tmp_path): contract_path = self._write_contract( tmp_path, From f826913a38c974a91b90d3857bed0b0064705068 Mon Sep 17 00:00:00 2001 From: Speculator55005 <50082482+fas89@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:31:37 +0100 Subject: [PATCH 2/3] docs(changelog): fluid bundle's default output, and the upgrade note --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14dcfdc4..2ecc33e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **`fluid bundle` without `--out` does what its help says on every contract.** With a + `fragments/` directory beside the contract, `yaml` and `json` went to a file, + `contract.bundled.fluid.yaml`, instead of stdout, so `fluid bundle contract.fluid.yaml | ...` + received nothing; and `--format tgz` wrote its gzip tarball under that `.yaml` name, which the + next stage read as YAML (`'utf-8' codec can't decode byte 0x8b`). `fluid ship` bundled that way + on every run. Now `yaml` and `json` go to stdout and a `tgz` to + `.fluid.bundle.tgz`, as for any other contract. + +### Upgrade notes + +- **`fluid bundle` no longer writes `contract.bundled.fluid.yaml` on its own.** A script that + bundled a split contract and then read that file must pass `--out contract.bundled.fluid.yaml`. + A `contract.bundled.fluid.yaml` an earlier `fluid ship` left beside a contract is a tarball and + can be deleted. + ## [0.25.0] — 2026-10-28 ### Added From 5f2d324bcba037a52d20030392065bf6b8d4234d Mon Sep 17 00:00:00 2001 From: Speculator55005 <50082482+fas89@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:40:09 +0100 Subject: [PATCH 3/3] fix(bundle): the default tgz path gets the write check --out gets, and the review notes From the reviews of this branch: - the default `.fluid.bundle.tgz` skipped the `validate_cli_path` write check an explicit --out gets, so a symlink planted at that name could point the write at a forbidden path (as root in a CI container); it is refused now (security review, LOW, pre-existing); - the stdout test for --format json read the output with yaml.safe_load, which would have passed a json format that silently printed YAML; it reads JSON; - CHANGELOG: `--out -` was redirected too, --sign/--attest failed on such a layout, and a leftover contract.bundled.fluid.yaml can be a stale copy. --- CHANGELOG.md | 21 ++++++++++++++------- fluid_build/cli/bundle.py | 11 ++++++++++- tests/forge/test_bundle_core.py | 24 +++++++++++++++++++++++- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ecc33e1..d4095932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,20 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **`fluid bundle` without `--out` does what its help says on every contract.** With a - `fragments/` directory beside the contract, `yaml` and `json` went to a file, +- **`fluid bundle` without `--out` (or with `--out -`) does what its help says on every + contract.** With a `fragments/` directory beside the contract, `yaml` and `json` went to a file, `contract.bundled.fluid.yaml`, instead of stdout, so `fluid bundle contract.fluid.yaml | ...` received nothing; and `--format tgz` wrote its gzip tarball under that `.yaml` name, which the - next stage read as YAML (`'utf-8' codec can't decode byte 0x8b`). `fluid ship` bundled that way - on every run. Now `yaml` and `json` go to stdout and a `tgz` to - `.fluid.bundle.tgz`, as for any other contract. + next stage read as YAML (`'utf-8' codec can't decode byte 0x8b`) and `--sign` / `--attest` + refused (`signing_bundle_wrong_format`). `fluid ship` bundled that way on every run. Now `yaml` + and `json` go to stdout and a `tgz` to `.fluid.bundle.tgz`, as for any other + contract. ### Upgrade notes - **`fluid bundle` no longer writes `contract.bundled.fluid.yaml` on its own.** A script that bundled a split contract and then read that file must pass `--out contract.bundled.fluid.yaml`. - A `contract.bundled.fluid.yaml` an earlier `fluid ship` left beside a contract is a tarball and - can be deleted. + A `contract.bundled.fluid.yaml` an earlier `fluid ship` left beside a contract is a tarball, and + one an earlier `fluid bundle` left is a copy of the contract that may be stale; delete either. + +### Security + +- **The default `fluid bundle --format tgz` path gets the write check an explicit `--out` gets.** + A symlink planted at `.fluid.bundle.tgz` could point the write at a forbidden path + (`/etc`, as root in a CI container); it is refused now, as `--out` with that path always was. ## [0.25.0] — 2026-10-28 diff --git a/fluid_build/cli/bundle.py b/fluid_build/cli/bundle.py index 64122e39..5a9dd0cd 100644 --- a/fluid_build/cli/bundle.py +++ b/fluid_build/cli/bundle.py @@ -429,7 +429,16 @@ def run(args: argparse.Namespace, logger: logging.Logger) -> int: default_name = f"{_slug(str(contract_id_raw))}.fluid.bundle.tgz" else: default_name = "contract.fluid.bundle.tgz" - out = str(contract_dir / default_name) + # The write check an explicit --out gets: a symlink planted at the default + # name cannot point the write at a forbidden path. + out = str( + validate_cli_path( + str(contract_dir / default_name), + mode="write", + must_exist=False, + file_type="output", + ) + ) sys.stderr.write( f"â„šī¸ --out not specified; defaulting to {out}\n" f" (derived from contract.id; override with --out )\n" diff --git a/tests/forge/test_bundle_core.py b/tests/forge/test_bundle_core.py index df5f9cbf..3fd523ac 100644 --- a/tests/forge/test_bundle_core.py +++ b/tests/forge/test_bundle_core.py @@ -555,11 +555,33 @@ def test_a_fragment_layout_prints_the_bundle_to_stdout(self, tmp_path, capsys, f args = argparse.Namespace(contract=str(contract_path), out="-", env=None, format=fmt) assert run(args, logging.getLogger("test")) == 0 - document = yaml.safe_load(capsys.readouterr().out) # JSON is YAML too + out = capsys.readouterr().out + document = json.loads(out) if fmt == "json" else yaml.safe_load(out) assert document["id"] == "customer-360" assert document["exposes"][0]["exposeId"] == "orders" assert not (tmp_path / "contract.bundled.fluid.yaml").exists() + def test_the_default_tgz_path_gets_the_write_check_an_explicit_out_gets(self, tmp_path): + """A symlink planted at ``.fluid.bundle.tgz`` cannot point the write at a + forbidden path: the default goes through the same check as ``--out``.""" + import argparse + import logging + + from fluid_build.cli.bundle import run + from fluid_build.cli.core import FluidCLIError + + contract_path = self._write_contract( + tmp_path, "fluidVersion: '0.7.2'\nkind: DataProduct\nid: customer-360\n" + ) + (tmp_path / "customer-360.fluid.bundle.tgz").symlink_to("/etc/fluid-bundle-probe.tgz") + args = argparse.Namespace(contract=str(contract_path), out="-", env=None, format="tgz") + + with pytest.raises(FluidCLIError) as caught: + run(args, logging.getLogger("test")) + + assert "forbidden" in caught.value.event + assert not Path("/etc/fluid-bundle-probe.tgz").exists() + def test_explicit_out_overrides_default(self, tmp_path): contract_path = self._write_contract( tmp_path,