Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<contract id>.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 `<contract id>.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`
Expand Down Expand Up @@ -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

Expand Down
25 changes: 18 additions & 7 deletions fluid_build/cli/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "
"<contract dir>/<contract id>.fluid.bundle.tgz)"
),
)
p.add_argument(
"--env",
Expand Down Expand Up @@ -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 ``<contract id>.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)

Expand Down Expand Up @@ -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 <path>)\n"
Expand Down
67 changes: 67 additions & 0 deletions tests/forge/test_bundle_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from pathlib import Path

import pytest
import yaml

from fluid_build.forge.core.bundle import (
MANIFEST_VERSION,
Expand Down Expand Up @@ -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 ``<id>.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,
Expand Down
Loading