diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fad5e4..ce9fd1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **`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`) 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. + +### 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. +- **`fluid validate` sent a terminal escape sequence in a message to the terminal.** An argument + or a contract value it quotes back (a `$ref` in `contract_load_failed`) could retitle the + window, rewrite lines or, where the terminal allows it, write the clipboard. Control characters + in those messages are now printed as text (`\x1b`), as the CLI's own error line already did. + ### Fixed - **`fluid validate --fragment` no longer guesses a schema version.** With no `--schema-version` @@ -37,12 +58,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`fluid validate missing.fluid.yaml` said "Contract file not found: None"**; it names the file again. -### Security +### Upgrade notes -- **`fluid validate` sent a terminal escape sequence in a message to the terminal.** An argument - or a contract value it quotes back (a `$ref` in `contract_load_failed`) could retitle the - window, rewrite lines or, where the terminal allows it, write the clipboard. Control characters - in those messages are now printed as text (`\x1b`), as the CLI's own error line already did. +- **`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 + one an earlier `fluid bundle` left is a copy of the contract that may be stale; delete either. ## [0.25.0] — 2026-10-28 diff --git a/fluid_build/cli/bundle.py b/fluid_build/cli/bundle.py index 44dd0859..5a9dd0cd 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) @@ -427,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 44d2ef15..3fd523ac 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,72 @@ 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 + 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,