diff --git a/README.md b/README.md index 2afae90..039d2f1 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,15 @@ regression plans and physical-print evidence. It is designed to work with desktop slicers already installed by the user, beginning with Bambu Studio and later OrcaSlicer. -The first development milestone can: +The current pre-alpha milestone can: - discover a local Bambu Studio or OrcaSlicer executable; - report the detected slicer version without using a cloud account; - fingerprint an STL or 3MF input; - produce an inspectable, shell-free Bambu Studio slicing command; -- emit a JSON plan that can become part of a reproducible regression report. +- emit a JSON plan that can become part of a reproducible regression report; +- execute one explicitly approved slice in a new isolated directory; +- record a portable run manifest, output hashes, and a redacted slicer log. OpenPrintBench does not upload models, control printers, use Bambu Cloud, request access tokens, or load proprietary network plugins. @@ -23,8 +25,9 @@ request access tokens, or load proprietary network plugins. ## Status -Pre-alpha. The current code creates and validates slicing plans; it does not -yet execute a slice or claim physical-print validation. See +Pre-alpha. A run requires an explicit `--approve` flag and a fixture with pinned +source and license provenance. The project does not claim physical-print +validation. See [the roadmap](docs/ROADMAP.md), [daily progress log](docs/PROGRESS.md), and [evidence-gated promotion plan](docs/PROMOTION.md). @@ -68,6 +71,39 @@ uv run openprintbench plan \ The generated command is an argument array. OpenPrintBench does not construct or execute a shell string. +Execute an isolated run only after reviewing the plan and fixture provenance: + +```bash +uv run openprintbench run \ + --input fixtures/cube-20mm.stl \ + --run-dir /tmp/openprintbench-run-001 \ + --machine-settings path/to/machine.json \ + --process-settings path/to/process.json \ + --filament-settings path/to/filament.json \ + --profile-root path/to/installed/profiles/BBL \ + --profile-source-url \ + https://github.com/bambulab/BambuStudio/tree/FULL_COMMIT/resources/profiles/BBL \ + --profile-source-commit FULL_COMMIT \ + --profile-license AGPL-3.0-only \ + --fixture-source-url \ + https://github.com/kforris/OpenPrintBench/blob/FULL_COMMIT/fixtures/cube-20mm.stl \ + --fixture-source-commit FULL_COMMIT \ + --fixture-license CC0-1.0 \ + --approve +``` + +The run directory must not already exist. OpenPrintBench gives the slicer a +private `HOME` and temporary directory, removes secret-like environment +variables, captures stdout/stderr into a redacted log, and fingerprints regular +files under the isolated output directory. For STL inputs, it resolves the +installed Bambu profile inheritance into complete private run-local JSON files +and records both source and materialized hashes. Generated 3MF/G-code remains +local and must not be committed. + +See the first +[repeatability evidence](docs/evidence/2026-07-28-bambu-stl-repeatability.md) +for two runs using the project-authored CC0 cube fixture. + ## Development ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 43d6481..017d421 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,31 @@ The Python package currently contains: - `slicers/` — slicer-specific command construction; - `fingerprint.py` — streaming SHA-256 file fingerprints; - `plan.py` — portable JSON plan assembly; -- `cli.py` — user-facing `doctor` and `plan` commands. +- `profiles.py` — pinned, local Bambu profile inheritance materialization; +- `run.py` — explicit approval, isolated process execution, output + fingerprinting, and privacy-reviewed run manifests; +- `cli.py` — user-facing `doctor`, `plan`, and `run` commands. Slicers are executed as separate local processes using argument arrays. OpenPrintBench does not link to, embed, or redistribute slicer code. + +## Run isolation + +Execution is opt-in and creates a new run directory. The slicer receives that +directory as its working directory, a private `HOME`, a private temporary +directory, and an environment with secret-like variables removed. Existing run +directories are rejected to avoid mixing evidence. + +The run manifest uses placeholders instead of machine-local paths. It records +the full fixture source commit and license, input/settings hashes, slicer +version, start time, duration, exit status, timeout state, output hashes, and +the hash of a redacted log. It sets `state` to `executed` and keeps +`physical_validation` null; a successful process does not imply a physical +print. + +For a bare STL, Bambu Studio's CLI needs complete machine, process, and +filament configuration. OpenPrintBench recursively resolves `inherits` and +`include` references from a user-installed profile tree, then writes complete +profiles inside the private run directory. The source tree commit, license, +leaf hashes, materializer version, and resulting hashes are recorded. Profile +source files and generated slicer outputs are never added to the repository. diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index fe3aeec..1ca80f8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -64,6 +64,24 @@ instead of manufacturing activity. - Kept X/Twitter cadence at no more than two original milestone posts or threads per week and excluded automated likes, follows, reposts, quote-posts, private messages, and generic engagement. +- Added the first redistributable fixture: a project-authored 20 mm ASCII STL + cube released under CC0-1.0, with a sidecar license and pinned SHA-256. +- Implemented the explicitly approved Bambu Studio executor linked to Issue + #1. It uses an argument array with `shell=False`, a new private run + directory, isolated `HOME`/temporary paths, secret-like environment removal, + a bounded timeout, redacted logging, and portable evidence manifests. +- Added a local profile materializer because Bambu Studio CLI requires complete + machine/process/filament settings rather than installed leaf profiles. + Source commit/license, leaf hashes, materializer version, and resulting + profile hashes are recorded without redistributing the profiles. +- Completed two successful Bambu Studio `02.06.00.51` digital slices with the + same fixture and settings. Both exited `0`; the G-code SHA-256 was identical. + Start time, duration, log timestamps, timing fields in `result.json`, and the + 3MF container hash varied. No generated 3MF/G-code was committed and no + physical-print claim was made. +- Audited GitHub and the authorized `@kforris_w` X account before development. + Issue #1 was the only open repository item; there were no open PRs, relevant + X mentions, or OpenPrintBench search results. No X reply or post was needed. ### Evidence @@ -72,25 +90,45 @@ instead of manufacturing activity. - P0 draft: `docs/promotion/P0_BUILDING_IN_PUBLIC.md` - X destination: - Public baseline CI remains green: - + +- Fixture SHA-256: + `369c23daac96f4cde40ec6a0e13afb9be5ec4cbbf974c071c60f1009824477c4` +- Bambu profile source: + `b506005bc4ee62124e24bf00e0f58656db3646a6` (`AGPL-3.0-only`) +- Repeatability record: + `docs/evidence/2026-07-28-bambu-stl-repeatability.md` +- Run 1: duration `0.650262` seconds, exit `0`, G-code SHA-256 + `f6c6365d65ecf1110f4aefd1097558378006ad4f1738648fba74c0bfa95205c5`. +- Run 2: duration `0.472667` seconds, exit `0`, G-code SHA-256 + `f6c6365d65ecf1110f4aefd1097558378006ad4f1738648fba74c0bfa95205c5`. +- Local validation after implementation: Ruff lint/format, mypy, 45 tests, + 0 skipped/xfail, 87.38% branch coverage, package build, and + `git diff --check` passed. +- Functional PR: +- PR CI (Python 3.11, 3.12, and 3.13): + +- No public release exists yet. ### Blockers -- No functional PR has been opened or merged yet, so the P0 publication gate is - not met. -- No redistributable STL/3MF fixture has been selected yet. +- The first functional PR is not merged and its `main` CI gate is not yet + satisfied, so P0 publication remains blocked. +- Only one of the ten required redistributable fixtures is complete. - The target Bambu printer model is not yet recorded. +- OrcaSlicer is not installed locally and no verified CI fixture is available. ### Next -1. Select the first open fixture and implement the isolated Bambu Studio - execution path in a functional PR linked to Issue #1. +1. Review the functional PR linked to Issue #1 and merge only after its CI is + green; then verify the resulting `main` CI. 2. After that PR is merged and `main` CI is green, create and verify the P0 visual, refresh the exact post copy, and publish through the authorized X account. -3. Check GitHub issues/PRs and relevant X replies before selecting daily work; +3. Add the next license-clear fixture or generator without weakening the + provenance gate. +4. Check GitHub issues/PRs and relevant X replies before selecting daily work; reply with evidence after changes and close only resolved GitHub issues. -4. Measure public repository and X signals 24 and 72 hours after the first +5. Measure public repository and X signals 24 and 72 hours after the first post. ## 2026-07-29 @@ -147,3 +185,59 @@ instead of manufacturing activity. 4. When the P0 gate is satisfied, refresh the exact copy and metrics, create and inspect the evidence-backed 1600x900 visual, then publish and verify the authorized two-post thread. + +## 2026-07-30 + +### Completed + +- Refreshed the public repository before selecting work. `origin/main` was + `2e61c5c29d7522d4aa4be8dcf6afdb563bde8121`; Issue #1 and PR #2 remained + open, with no new review, review comment, issue comment, or external + contribution. +- Found PR #2 had changed from mergeable/clean to conflicting/dirty after the + 2026-07-29 progress-only commit modified `docs/PROGRESS.md` on `main`. +- Selected one bounded unblocker instead of opening a second implementation + PR: merged the latest `origin/main` into the existing PR branch and resolved + the progress-log conflict without dropping either day's verified record. +- Verified the existing Comet X session was logged in as `@kforris_w`. The + mentions view was empty and an exact latest search for `OpenPrintBench` + returned no results after one retry. No private message was inspected and no + X reply or post was made. + +### Evidence + +- Open Issue #1: +- Open PR #2: +- Pre-resolution PR state: `CONFLICTING` / `DIRTY` at + `817e8a7c8fb7c21264fead9229d277d3da55b6e2`, with no reviews or comments. +- Previous PR CI: + +- Latest baseline `main` CI: + +- Local validation after conflict resolution: `uv lock --check`, Ruff + lint/format, mypy, 45 tests with 0 skipped/xfail and 87.38% branch coverage, + package build, and `git diff --check` passed. +- Public repository snapshot: 1 star, 0 forks, no tags, and no releases. + +### Blockers + +- PR #2 is still unmerged and requires review/merge; its resulting green + `main` CI does not yet exist, so Issue #1 cannot be closed. +- The P0 visual and two-post thread remain blocked on the merged functional PR + and resulting green `main` CI. +- The target Bambu printer model, material, and human observation remain + unavailable; no physical validation is claimed. +- OrcaSlicer is not installed locally, and nine additional redistributable + fixtures or generators remain to reach the v0.1 fixture target. + +### Next + +1. Verify the updated PR branch CI and mergeability, then wait for real review + and merge without self-merging. +2. After PR #2 is merged, verify the resulting `main` CI, update and close + Issue #1 with durable evidence, and mark only the proven roadmap items. +3. When the P0 gate is satisfied, refresh the exact copy and metrics, create + and inspect the evidence-backed 1600x900 visual, then publish and verify the + authorized two-post thread. +4. Add the next license-clear fixture or generator only after the current + implementation PR is no longer blocking the one-topic workflow. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7964371..e51ace0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,9 +9,10 @@ requirements and do not guarantee acceptance into any support program. - [x] Probe Bambu Studio version and supported command-line options. - [x] Produce a deterministic, shell-free slice plan. - [x] Fingerprint source models with SHA-256. -- [ ] Execute an explicitly approved Bambu Studio slice in an isolated run - directory. -- [ ] Capture duration, exit status, output hashes, and a redacted log. +- [x] Execute an explicitly approved Bambu Studio slice in an isolated run + directory. See the + [2026-07-28 evidence](evidence/2026-07-28-bambu-stl-repeatability.md). +- [x] Capture duration, exit status, output hashes, and a redacted log. - [ ] Add an OrcaSlicer execution adapter after local installation or a verified CI fixture is available. - [ ] Publish at least ten redistributable test fixtures or generators. diff --git a/docs/evidence/2026-07-28-bambu-stl-repeatability.md b/docs/evidence/2026-07-28-bambu-stl-repeatability.md new file mode 100644 index 0000000..2bd1e32 --- /dev/null +++ b/docs/evidence/2026-07-28-bambu-stl-repeatability.md @@ -0,0 +1,76 @@ +# Bambu STL repeatability evidence — 2026-07-28 + +This record covers two local, explicitly approved digital slicing runs. It +does not claim a physical print, printer control, cloud access, or hardware +validation. + +## Provenance + +- OpenPrintBench fixture commit: + `2d2c54e9d9ddc53a365606b9d9d070b94caeb64c` +- Fixture: `fixtures/cube-20mm.stl`, project-authored, `CC0-1.0` +- Fixture SHA-256: + `369c23daac96f4cde40ec6a0e13afb9be5ec4cbbf974c071c60f1009824477c4` +- Fixture size: `1517` bytes +- Slicer: Bambu Studio `02.06.00.51` +- Installed profile source commit: + `b506005bc4ee62124e24bf00e0f58656db3646a6` +- Installed profile source license: `AGPL-3.0-only` +- Materializer: `openprintbench-bambu-profile-merge-v1` +- Machine: `Bambu Lab X1 Carbon 0.4 nozzle.json` +- Process: `0.20mm Standard @BBL X1C.json` +- Filament: `Generic PLA.json` + +The selected profile files were used only as local inputs. Their inheritance +was materialized into the isolated run directory; neither those profiles nor +generated 3MF/G-code files are stored in this repository. + +## Captured development attempts + +Before the two successful runs, the executor captured two non-success states +without producing output files: + +- Installed leaf profiles were passed directly: duration `0.278480` seconds, + exit status `-11` (signal 11), redacted log SHA-256 + `f1de4e2a8b778b70ef41d56836046f5e73b8e07aa756e46471a11e3850dcc3fc`. + This led to the complete-profile materializer. +- Materialized profiles were used with an incomplete orientation argument: + duration `0.070092` seconds, exit status `254`, redacted log SHA-256 + `5a9942777f3bb22d04188093b67f318b551a4b09ad90b9430f289b1aeb410a8d`. + The argument was corrected to the documented `--orient 1` form. + +Both manifests retained `physical_validation: null`. + +## Run results + +| Field | Run 1 | Run 2 | Classification | +| --- | --- | --- | --- | +| Exit status | `0` | `0` | stable | +| Timed out | `false` | `false` | stable | +| Duration (seconds) | `0.650262` | `0.472667` | expected variable | +| G-code size (bytes) | `278335` | `278335` | stable | +| G-code SHA-256 | `f6c6365d65ecf1110f4aefd1097558378006ad4f1738648fba74c0bfa95205c5` | same | stable | +| 3MF size (bytes) | `55621` | `55621` | stable | +| 3MF SHA-256 | `57ce07f1176c466cd20a97908038a5c3a8d167937eec796544bb73750d0d3c9d` | `146dcd84ea9ed468ca52abc8e8149d1d44b4310445dd341d71aa9b126f6bab31` | variable container bytes | +| `result.json` SHA-256 | `d97fc5b7900c937e64bfb114a26089fa0196ec79dbfbb7210147baed2dbcb547` | `17cee9b309239fe0dc6a74681a76f73ca936f58e1aec10d53047a1ccdf591e23` | variable timing fields | +| Redacted log SHA-256 | `5d701094d4e0cd5535a456dd9c28b1923070f83a007eeee9b055e0e59c41429e` | `25fa3d3424d53cddee09f9dd3a0330000908a9fd6d01b9be5956b1c3586f3550` | variable timestamps | +| Physical validation | `null` | `null` | not performed | + +## Stable inputs and settings + +The portable command array, fixture hash, slicer version, source profile +hashes, materialized profile hashes, output names, approval state, and exit +status matched across both runs. In particular: + +- materialized machine SHA-256: + `b5a60ef9b156acce52fa5b083595af1a00d7c478ccdc05f1b7f4e59b5e6c6a7b` +- materialized process SHA-256: + `5d6ec0717d02b0293d45798d3c776d141e6a3f03876ee519863028de6b992039` +- materialized filament SHA-256: + `2ee424b81096dbdf4f6c8e09a7f7f46a840a5b44fa857db19c290871ff2c67b8` + +The differing `result.json` fields were execution-time measurements such as +`export_time`, `prepare_time`, perimeter/infill time, and total sliced time. +The redacted logs differed at timestamp positions. The matching G-code hash is +the strongest byte-level repeatability result from these two runs; the varying +3MF container hash must not be treated as a slicing regression by itself. diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..4f6ae8e --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,18 @@ +# Redistributable fixtures + +`cube-20mm.stl` is an original, deterministic ASCII STL created for +OpenPrintBench. It contains a 20 mm cube represented by 12 triangles and is +dedicated to the public domain under CC0-1.0. Its adjacent `.license` file is +the authoritative redistribution notice. + +For an evidence run, pin the fixture to the full Git commit that contains the +exact bytes and use: + +- source URL: + `https://github.com/kforris/OpenPrintBench/blob//fixtures/cube-20mm.stl` +- source commit: the same full 40-character Git SHA; +- license: `CC0-1.0`; +- input SHA-256: calculated by OpenPrintBench at execution time. + +The fixture is digital test geometry only. Its presence does not imply that a +slice has run or that a physical print has been validated. diff --git a/fixtures/cube-20mm.stl b/fixtures/cube-20mm.stl new file mode 100644 index 0000000..7cb0356 --- /dev/null +++ b/fixtures/cube-20mm.stl @@ -0,0 +1,86 @@ +solid openprintbench_cube_20mm + facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 20 20 0 + vertex 20 0 0 + endloop + endfacet + facet normal 0 0 -1 + outer loop + vertex 0 0 0 + vertex 0 20 0 + vertex 20 20 0 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex 0 0 20 + vertex 20 0 20 + vertex 20 20 20 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex 0 0 20 + vertex 20 20 20 + vertex 0 20 20 + endloop + endfacet + facet normal 0 -1 0 + outer loop + vertex 0 0 0 + vertex 20 0 0 + vertex 20 0 20 + endloop + endfacet + facet normal 0 -1 0 + outer loop + vertex 0 0 0 + vertex 20 0 20 + vertex 0 0 20 + endloop + endfacet + facet normal 1 0 0 + outer loop + vertex 20 0 0 + vertex 20 20 0 + vertex 20 20 20 + endloop + endfacet + facet normal 1 0 0 + outer loop + vertex 20 0 0 + vertex 20 20 20 + vertex 20 0 20 + endloop + endfacet + facet normal 0 1 0 + outer loop + vertex 20 20 0 + vertex 0 20 0 + vertex 0 20 20 + endloop + endfacet + facet normal 0 1 0 + outer loop + vertex 20 20 0 + vertex 0 20 20 + vertex 20 20 20 + endloop + endfacet + facet normal -1 0 0 + outer loop + vertex 0 20 0 + vertex 0 0 0 + vertex 0 0 20 + endloop + endfacet + facet normal -1 0 0 + outer loop + vertex 0 20 0 + vertex 0 0 20 + vertex 0 20 20 + endloop + endfacet +endsolid openprintbench_cube_20mm diff --git a/fixtures/cube-20mm.stl.license b/fixtures/cube-20mm.stl.license new file mode 100644 index 0000000..fe689e1 --- /dev/null +++ b/fixtures/cube-20mm.stl.license @@ -0,0 +1,6 @@ +SPDX-License-Identifier: CC0-1.0 + +This deterministic 20 mm cube was authored for OpenPrintBench. To the extent +possible under law, the contributor has waived all copyright and related or +neighboring rights to this fixture under CC0 1.0 Universal: +https://creativecommons.org/publicdomain/zero/1.0/ diff --git a/src/openprintbench/cli.py b/src/openprintbench/cli.py index ca71ace..b1ca364 100644 --- a/src/openprintbench/cli.py +++ b/src/openprintbench/cli.py @@ -10,7 +10,9 @@ from openprintbench import __version__ from openprintbench.discovery import DEFINITIONS, probe_slicer +from openprintbench.models import FixtureProvenance, ProfileProvenance from openprintbench.plan import create_bambu_plan, portable_plan +from openprintbench.run import execute_bambu_slice from openprintbench.slicers.bambu import BambuSliceRequest @@ -36,6 +38,31 @@ def build_parser() -> argparse.ArgumentParser: plan.add_argument("--process-settings", type=Path) plan.add_argument("--filament-settings", type=Path, action="append", default=[]) plan.add_argument("--manifest", type=Path, help="write the portable JSON plan") + + run = subparsers.add_parser("run", help="execute one explicitly approved isolated slice") + run.add_argument("--slicer", choices=("bambu",), default="bambu") + run.add_argument("--executable", type=Path) + run.add_argument("--input", type=Path, required=True) + run.add_argument("--run-dir", type=Path, required=True) + run.add_argument("--output-name", default="sliced.3mf") + run.add_argument("--plate", type=int, default=0) + run.add_argument("--debug-level", type=int, default=2) + run.add_argument("--machine-settings", type=Path) + run.add_argument("--process-settings", type=Path) + run.add_argument("--filament-settings", type=Path, action="append", default=[]) + run.add_argument("--fixture-source-url", required=True) + run.add_argument("--fixture-source-commit", required=True) + run.add_argument("--fixture-license", required=True) + run.add_argument("--profile-root", type=Path) + run.add_argument("--profile-source-url") + run.add_argument("--profile-source-commit") + run.add_argument("--profile-license") + run.add_argument("--timeout-seconds", type=float, default=900.0) + run.add_argument( + "--approve", + action="store_true", + help="explicitly approve launching the local slicer process", + ) return parser @@ -50,6 +77,8 @@ def main(argv: Sequence[str] | None = None) -> int: return _doctor(as_json=args.json) if args.command == "plan": return _plan(args) + if args.command == "run": + return _run(args) except ValueError as error: parser.error(str(error)) @@ -100,5 +129,48 @@ def _plan(args: argparse.Namespace) -> int: return 0 +def _run(args: argparse.Namespace) -> int: + probe = probe_slicer("bambu", args.executable) + if not probe.available or probe.executable is None: + raise ValueError(f"Bambu Studio probe failed: {probe.error or 'unknown error'}") + + run_dir = args.run_dir.expanduser().resolve() + request = BambuSliceRequest( + executable=Path(probe.executable), + input_path=args.input, + output_dir=run_dir / "output", + output_name=args.output_name, + plate=args.plate, + debug_level=args.debug_level, + machine_settings=args.machine_settings, + process_settings=args.process_settings, + filament_settings=tuple(args.filament_settings), + ) + profile_values = ( + args.profile_source_url, + args.profile_source_commit, + args.profile_license, + ) + if any(profile_values) and not all(profile_values): + raise ValueError("profile source URL, commit, and license must be provided together") + profile_provenance = ProfileProvenance(*profile_values) if all(profile_values) else None + evidence = execute_bambu_slice( + request, + probe, + run_dir=run_dir, + provenance=FixtureProvenance( + source_url=args.fixture_source_url, + source_commit=args.fixture_source_commit, + license=args.fixture_license, + ), + profile_root=args.profile_root, + profile_provenance=profile_provenance, + approved=args.approve, + timeout_seconds=args.timeout_seconds, + ) + print(Path(evidence.manifest_path).read_text(encoding="utf-8"), end="") + return 0 if evidence.succeeded else 1 + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/openprintbench/models.py b/src/openprintbench/models.py index c3b334f..02eec95 100644 --- a/src/openprintbench/models.py +++ b/src/openprintbench/models.py @@ -57,3 +57,62 @@ def to_dict(self) -> dict[str, Any]: result = asdict(self) result["command"] = list(self.command) return result + + +@dataclass(frozen=True, slots=True) +class FixtureProvenance: + """Pinned redistribution information for a slice input.""" + + source_url: str + source_commit: str + license: str + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serializable mapping.""" + + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class ProfileProvenance: + """Pinned source and license for locally materialized slicer profiles.""" + + source_url: str + source_commit: str + license: str + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serializable mapping.""" + + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class OutputFingerprint: + """Stable identity for one file produced inside the isolated output tree.""" + + relative_path: str + size_bytes: int + sha256: str + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-serializable mapping.""" + + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class RunEvidence: + """Truthful evidence from one explicitly approved slicer process.""" + + manifest_path: str + log_path: str + exit_status: int | None + timed_out: bool + output_count: int + + @property + def succeeded(self) -> bool: + """Return whether the slicer exited successfully before the timeout.""" + + return not self.timed_out and self.exit_status == 0 diff --git a/src/openprintbench/profiles.py b/src/openprintbench/profiles.py new file mode 100644 index 0000000..2ab20ec --- /dev/null +++ b/src/openprintbench/profiles.py @@ -0,0 +1,134 @@ +"""Materialize complete Bambu CLI profiles from installed profile inheritance.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +PROFILE_CATEGORIES = ("machine", "process", "filament") +MATERIALIZER_VERSION = "openprintbench-bambu-profile-merge-v1" + + +@dataclass(frozen=True, slots=True) +class MaterializedProfiles: + """Paths to complete profile JSON written inside one isolated run.""" + + machine: Path + process: Path + filaments: tuple[Path, ...] + + +class BambuProfileStore: + """Resolve named profile inheritance from an installed BBL profile tree.""" + + def __init__(self, root: Path) -> None: + self.root = root.expanduser().resolve() + if not self.root.is_dir(): + raise ValueError(f"Bambu profile root is not a directory: {root}") + self._indexes = {category: self._build_index(category) for category in PROFILE_CATEGORIES} + + def materialize( + self, + *, + machine: Path, + process: Path, + filaments: tuple[Path, ...], + destination: Path, + ) -> MaterializedProfiles: + """Write complete profiles under a private run directory.""" + + if not filaments: + raise ValueError("at least one filament profile is required") + prepared = { + "machine": (machine, "machine.json"), + "process": (process, "process.json"), + } + destination.mkdir(mode=0o700) + written: dict[str, Path] = {} + for category, (source, name) in prepared.items(): + written[category] = self._write_profile(category, source, destination / name) + + filament_paths = tuple( + self._write_profile( + "filament", + source, + destination / f"filament-{index}.json", + ) + for index, source in enumerate(filaments, start=1) + ) + return MaterializedProfiles( + machine=written["machine"], + process=written["process"], + filaments=filament_paths, + ) + + def _build_index(self, category: str) -> dict[str, tuple[Path, dict[str, Any]]]: + category_root = self.root / category + if not category_root.is_dir(): + raise ValueError(f"Bambu profile category is missing: {category_root}") + index: dict[str, tuple[Path, dict[str, Any]]] = {} + for path in sorted(category_root.rglob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"invalid Bambu profile JSON: {path}") from error + if not isinstance(data, dict): + raise ValueError(f"Bambu profile must contain a JSON object: {path}") + name = data.get("name") + if not isinstance(name, str) or not name: + continue + if name in index: + raise ValueError(f"duplicate Bambu profile name in {category}: {name}") + index[name] = (path.resolve(), data) + return index + + def _write_profile(self, category: str, source: Path, destination: Path) -> Path: + resolved_source = source.expanduser().resolve() + index = self._indexes[category] + source_entry = next( + (entry for entry in index.values() if entry[0] == resolved_source), + None, + ) + if source_entry is None: + raise ValueError(f"{category} profile is outside the indexed profile tree: {source}") + leaf = source_entry[1] + leaf_name = leaf["name"] + assert isinstance(leaf_name, str) + merged = self._resolve(category, leaf_name, stack=()) + merged["from"] = "User" + merged["inherits"] = leaf_name + destination.write_text( + f"{json.dumps(merged, indent=2, sort_keys=True)}\n", + encoding="utf-8", + ) + return destination + + def _resolve( + self, + category: str, + name: str, + *, + stack: tuple[str, ...], + ) -> dict[str, Any]: + if name in stack: + chain = " -> ".join((*stack, name)) + raise ValueError(f"cyclic Bambu profile inheritance: {chain}") + try: + _, data = self._indexes[category][name] + except KeyError as error: + raise ValueError(f"unresolved Bambu {category} profile: {name}") from error + + merged: dict[str, Any] = {} + next_stack = (*stack, name) + parent = data.get("inherits") + if isinstance(parent, str) and parent: + merged.update(self._resolve(category, parent, stack=next_stack)) + includes = data.get("include", []) + if not isinstance(includes, list) or not all(isinstance(item, str) for item in includes): + raise ValueError(f"invalid include list in Bambu {category} profile: {name}") + for include in includes: + merged.update(self._resolve(category, include, stack=next_stack)) + merged.update(data) + return merged diff --git a/src/openprintbench/run.py b/src/openprintbench/run.py new file mode 100644 index 0000000..f55fb84 --- /dev/null +++ b/src/openprintbench/run.py @@ -0,0 +1,409 @@ +"""Explicit, isolated slicer execution with privacy-reviewed evidence.""" + +from __future__ import annotations + +import getpass +import json +import os +import re +import subprocess +from dataclasses import replace +from datetime import UTC, datetime +from pathlib import Path +from time import monotonic +from typing import Any + +from openprintbench.fingerprint import fingerprint_file +from openprintbench.models import ( + FixtureProvenance, + OutputFingerprint, + ProfileProvenance, + RunEvidence, + SlicerProbe, +) +from openprintbench.profiles import ( + MATERIALIZER_VERSION, + BambuProfileStore, +) +from openprintbench.slicers.bambu import BambuSliceRequest, build_bambu_slice_command + +RUN_SCHEMA_VERSION = "0.1" +DEFAULT_TIMEOUT_SECONDS = 900.0 +MAX_TIMEOUT_SECONDS = 3600.0 +SENSITIVE_ENV_MARKERS = ( + "AUTH", + "CREDENTIAL", + "KEY", + "PASSWORD", + "SECRET", + "SESSION", + "TOKEN", +) +SENSITIVE_ASSIGNMENT = re.compile( + r"(?i)\b(api[_-]?key|access[_ -]?code|authorization|password|passwd|" + r"secret|serial(?:[_ -]?number)?|token)\b(\s*[:=]\s*)([^\s,;]+)" +) +USER_HOME_PATH = re.compile(r"(? RunEvidence: + """Execute one approved Bambu Studio process and write a portable manifest.""" + + _validate_run_request( + request, + probe, + run_dir=run_dir, + provenance=provenance, + profile_root=profile_root, + profile_provenance=profile_provenance, + approved=approved, + timeout_seconds=timeout_seconds, + ) + profile_store = BambuProfileStore(profile_root) if profile_root is not None else None + resolved_run_dir = run_dir.expanduser().resolve() + resolved_run_dir.mkdir(mode=0o700) + output_dir = resolved_run_dir / "output" + output_dir.mkdir(mode=0o700) + private_home = resolved_run_dir / "home" + private_home.mkdir(mode=0o700) + private_tmp = resolved_run_dir / "tmp" + private_tmp.mkdir(mode=0o700) + + isolated_request = replace(request, output_dir=output_dir) + if profile_store is not None: + assert request.machine_settings is not None + assert request.process_settings is not None + materialized = profile_store.materialize( + machine=request.machine_settings, + process=request.process_settings, + filaments=request.filament_settings, + destination=resolved_run_dir / "config", + ) + isolated_request = replace( + isolated_request, + machine_settings=materialized.machine, + process_settings=materialized.process, + filament_settings=materialized.filaments, + ) + command = build_bambu_slice_command(isolated_request) + environment = _isolated_environment(private_home, private_tmp) + started_at = datetime.now(UTC) + started_clock = monotonic() + timed_out = False + exit_status: int | None + stdout = "" + stderr = "" + + try: + completed = subprocess.run( + command, + cwd=resolved_run_dir, + env=environment, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout_seconds, + shell=False, + ) + exit_status = completed.returncode + stdout = completed.stdout + stderr = completed.stderr + except subprocess.TimeoutExpired as error: + timed_out = True + exit_status = None + stdout = _timeout_text(error.stdout) + stderr = _timeout_text(error.stderr) + + duration_seconds = monotonic() - started_clock + redactions = _redaction_values(isolated_request, resolved_run_dir, private_home, private_tmp) + redacted_log = _redact_log( + _format_log(stdout, stderr, timed_out=timed_out), + redactions=redactions, + ) + log_path = resolved_run_dir / "slicer.log" + log_path.write_text(redacted_log, encoding="utf-8") + + outputs = _fingerprint_output_tree(output_dir) + manifest = _build_manifest( + isolated_request, + probe, + provenance, + source_request=request, + profile_provenance=profile_provenance, + command=command, + started_at=started_at, + duration_seconds=duration_seconds, + exit_status=exit_status, + timed_out=timed_out, + outputs=outputs, + log_path=log_path, + ) + _assert_manifest_private(manifest) + manifest_path = resolved_run_dir / "manifest.json" + manifest_path.write_text( + f"{json.dumps(manifest, indent=2, sort_keys=True)}\n", + encoding="utf-8", + ) + return RunEvidence( + manifest_path=str(manifest_path), + log_path=str(log_path), + exit_status=exit_status, + timed_out=timed_out, + output_count=len(outputs), + ) + + +def _validate_run_request( + request: BambuSliceRequest, + probe: SlicerProbe, + *, + run_dir: Path, + provenance: FixtureProvenance, + profile_root: Path | None, + profile_provenance: ProfileProvenance | None, + approved: bool, + timeout_seconds: float, +) -> None: + if not approved: + raise ValueError("slice execution requires explicit approval") + if probe.slicer != "bambu" or not probe.available or not probe.executable: + raise ValueError("Bambu Studio must be successfully probed before execution") + if Path(probe.executable).expanduser().resolve() != request.executable.expanduser().resolve(): + raise ValueError("request executable does not match the successful probe") + resolved_run_dir = run_dir.expanduser().resolve() + if resolved_run_dir.exists(): + raise ValueError(f"run directory must not already exist: {run_dir}") + if not resolved_run_dir.parent.is_dir(): + raise ValueError(f"run directory parent does not exist: {run_dir.parent}") + if not 0 < timeout_seconds <= MAX_TIMEOUT_SECONDS: + raise ValueError( + f"timeout must be greater than 0 and at most {MAX_TIMEOUT_SECONDS:g} seconds" + ) + _validate_provenance( + provenance.source_url, + provenance.source_commit, + provenance.license, + label="fixture", + ) + if request.input_path.suffix.lower() == ".stl" and ( + profile_root is None or profile_provenance is None + ): + raise ValueError("STL execution requires a profile root and pinned profile provenance") + if (profile_root is None) != (profile_provenance is None): + raise ValueError("profile root and profile provenance must be provided together") + if profile_provenance is not None: + _validate_provenance( + profile_provenance.source_url, + profile_provenance.source_commit, + profile_provenance.license, + label="profile", + ) + + +def _validate_provenance( + source_url: str, + source_commit: str, + license_name: str, + *, + label: str, +) -> None: + if not source_url.startswith("https://"): + raise ValueError(f"{label} source URL must use https") + if FULL_COMMIT.fullmatch(source_commit) is None: + raise ValueError(f"{label} source commit must be a full 40-character Git SHA") + if not license_name.strip(): + raise ValueError(f"{label} license must be recorded") + + +def _isolated_environment(private_home: Path, private_tmp: Path) -> dict[str, str]: + environment = { + key: value + for key, value in os.environ.items() + if not any(marker in key.upper() for marker in SENSITIVE_ENV_MARKERS) + } + environment.update( + { + "HOME": str(private_home), + "TMPDIR": str(private_tmp), + "XDG_CACHE_HOME": str(private_home / ".cache"), + "XDG_CONFIG_HOME": str(private_home / ".config"), + "XDG_DATA_HOME": str(private_home / ".local" / "share"), + } + ) + return environment + + +def _timeout_text(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value + + +def _format_log(stdout: str, stderr: str, *, timed_out: bool) -> str: + status = "timeout" if timed_out else "completed" + return f"[openprintbench] process={status}\n[stdout]\n{stdout}\n[stderr]\n{stderr}\n" + + +def _redaction_values( + request: BambuSliceRequest, + run_dir: Path, + private_home: Path, + private_tmp: Path, +) -> dict[str, str]: + values = { + str(Path.home()): "${HOME}", + str(run_dir): "${RUN_DIR}", + str(private_home): "${PRIVATE_HOME}", + str(private_tmp): "${PRIVATE_TMP}", + str(request.executable.expanduser().resolve()): "${SLICER}", + str(request.input_path.expanduser().resolve()): "${INPUT}", + str(request.output_dir.expanduser().resolve()): "${OUTPUT_DIR}", + } + if request.machine_settings is not None: + values[str(request.machine_settings.expanduser().resolve())] = "${MACHINE_SETTINGS}" + if request.process_settings is not None: + values[str(request.process_settings.expanduser().resolve())] = "${PROCESS_SETTINGS}" + for index, path in enumerate(request.filament_settings, start=1): + values[str(path.expanduser().resolve())] = f"${{FILAMENT_SETTINGS_{index}}}" + return values + + +def _redact_log(log: str, *, redactions: dict[str, str]) -> str: + result = log + for original, replacement in sorted( + redactions.items(), key=lambda item: len(item[0]), reverse=True + ): + if original: + result = result.replace(original, replacement) + result = USER_HOME_PATH.sub("${HOME}", result) + result = SENSITIVE_ASSIGNMENT.sub( + lambda match: f"{match.group(1)}{match.group(2)}", result + ) + return result + + +def _fingerprint_output_tree(output_dir: Path) -> tuple[OutputFingerprint, ...]: + fingerprints: list[OutputFingerprint] = [] + for path in sorted(output_dir.rglob("*")): + if path.is_symlink(): + raise ValueError( + f"output tree contains a symbolic link: {path.relative_to(output_dir)}" + ) + if not path.is_file(): + continue + fingerprint = fingerprint_file(path) + fingerprints.append( + OutputFingerprint( + relative_path=path.relative_to(output_dir).as_posix(), + size_bytes=fingerprint.size_bytes, + sha256=fingerprint.sha256, + ) + ) + return tuple(fingerprints) + + +def _build_manifest( + request: BambuSliceRequest, + probe: SlicerProbe, + provenance: FixtureProvenance, + source_request: BambuSliceRequest, + profile_provenance: ProfileProvenance | None, + *, + command: tuple[str, ...], + started_at: datetime, + duration_seconds: float, + exit_status: int | None, + timed_out: bool, + outputs: tuple[OutputFingerprint, ...], + log_path: Path, +) -> dict[str, Any]: + redactions = _redaction_values( + request, + request.output_dir.parent, + request.output_dir.parent / "home", + request.output_dir.parent / "tmp", + ) + portable_command = [_replace_paths(item, redactions) for item in command] + settings: dict[str, Any] = { + "materializer": MATERIALIZER_VERSION if profile_provenance is not None else None, + "provenance": profile_provenance.to_dict() if profile_provenance is not None else None, + "sources": { + "machine": _optional_fingerprint(source_request.machine_settings), + "process": _optional_fingerprint(source_request.process_settings), + "filaments": [ + fingerprint_file(path).to_dict() for path in source_request.filament_settings + ], + }, + "materialized": { + "machine": _optional_fingerprint(request.machine_settings), + "process": _optional_fingerprint(request.process_settings), + "filaments": [fingerprint_file(path).to_dict() for path in request.filament_settings], + }, + } + log_fingerprint = fingerprint_file(log_path) + return { + "schema_version": RUN_SCHEMA_VERSION, + "state": "executed", + "approval": "explicit_cli_flag", + "started_at": started_at.isoformat(), + "duration_seconds": round(duration_seconds, 6), + "exit_status": exit_status, + "timed_out": timed_out, + "slicer": { + "name": probe.display_name, + "version": probe.version, + "executable": "${SLICER}", + }, + "fixture": { + **provenance.to_dict(), + "input": fingerprint_file(request.input_path).to_dict(), + }, + "settings": settings, + "command": portable_command, + "outputs": [output.to_dict() for output in outputs], + "log": { + "name": log_path.name, + "size_bytes": log_fingerprint.size_bytes, + "sha256": log_fingerprint.sha256, + }, + "physical_validation": None, + } + + +def _optional_fingerprint(path: Path | None) -> dict[str, Any] | None: + return fingerprint_file(path).to_dict() if path is not None else None + + +def _replace_paths(value: str, redactions: dict[str, str]) -> str: + result = value + for original, replacement in sorted( + redactions.items(), key=lambda item: len(item[0]), reverse=True + ): + if original: + result = result.replace(original, replacement) + return result + + +def _assert_manifest_private(manifest: dict[str, Any]) -> None: + serialized = json.dumps(manifest, sort_keys=True) + forbidden = {str(Path.home()), f"/Users/{getpass.getuser()}", f"/home/{getpass.getuser()}"} + leaked = [value for value in forbidden if value and value in serialized] + if leaked: + raise ValueError("manifest contains a local home path") + if SENSITIVE_ASSIGNMENT.search(serialized): + raise ValueError("manifest contains a sensitive assignment") diff --git a/src/openprintbench/slicers/bambu.py b/src/openprintbench/slicers/bambu.py index c7e95ea..eba1918 100644 --- a/src/openprintbench/slicers/bambu.py +++ b/src/openprintbench/slicers/bambu.py @@ -74,6 +74,8 @@ def build_bambu_slice_command(request: BambuSliceRequest) -> tuple[str, ...]: "--outputdir", str(output_dir), ] + if suffix == ".stl": + command.extend(("--orient", "1", "--arrange", "1")) if settings: command.extend(("--load-settings", ";".join(str(path) for path in settings))) if filaments: diff --git a/tests/test_bambu.py b/tests/test_bambu.py index e7a7e6b..29e10b0 100644 --- a/tests/test_bambu.py +++ b/tests/test_bambu.py @@ -70,6 +70,8 @@ def test_stl_includes_settings(executable: Path, tmp_path: Path) -> None: filament_index = command.index("--load-filaments") assert command[settings_index + 1] == f"{machine.resolve()};{process.resolve()}" assert command[filament_index + 1] == str(filament.resolve()) + assert command[command.index("--orient") + 1] == "1" + assert command[command.index("--arrange") + 1] == "1" @pytest.mark.parametrize("plate", [-1, -10]) diff --git a/tests/test_cli.py b/tests/test_cli.py index cfa7cd6..53ca4f5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -85,3 +85,61 @@ def test_plan_writes_portable_manifest( file_payload = json.loads(manifest.read_text(encoding="utf-8")) assert stdout_payload == file_payload assert "${INPUT}" in file_payload["command"] + + +def test_run_executes_only_with_explicit_approval( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + executable = tmp_path / "BambuStudio" + executable.write_text( + """#!/usr/bin/env python3 +import pathlib +import sys +output_dir = pathlib.Path(sys.argv[sys.argv.index("--outputdir") + 1]) +output_name = sys.argv[sys.argv.index("--export-3mf") + 1] +output_dir.mkdir(parents=True, exist_ok=True) +(output_dir / output_name).write_bytes(b"slice") +""", + encoding="utf-8", + ) + executable.chmod(0o755) + source = tmp_path / "project.3mf" + source.write_bytes(b"3mf") + monkeypatch.setattr( + "openprintbench.cli.probe_slicer", + lambda name, explicit=None: SlicerProbe( + "bambu", + "Bambu Studio", + True, + str(executable), + "02.06.00.51", + "test", + ), + ) + + assert ( + main( + [ + "run", + "--input", + str(source), + "--run-dir", + str(tmp_path / "run"), + "--fixture-source-url", + "https://github.com/kforris/OpenPrintBench/blob/" + + "a" * 40 + + "/fixtures/cube-20mm.stl", + "--fixture-source-commit", + "a" * 40, + "--fixture-license", + "CC0-1.0", + "--approve", + ] + ) + == 0 + ) + payload = json.loads(capsys.readouterr().out) + assert payload["state"] == "executed" + assert payload["exit_status"] == 0 diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..a841427 --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,84 @@ +import json +from pathlib import Path + +import pytest + +from openprintbench.profiles import BambuProfileStore + + +def write_profile(root: Path, category: str, filename: str, payload: dict[str, object]) -> Path: + path = root / category / filename + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_materializer_merges_inheritance_and_includes(tmp_path: Path) -> None: + root = tmp_path / "profiles" + write_profile(root, "machine", "base.json", {"name": "base", "speed": "100"}) + write_profile(root, "machine", "template.json", {"name": "template", "gcode": "safe"}) + machine = write_profile( + root, + "machine", + "leaf.json", + { + "name": "leaf", + "inherits": "base", + "include": ["template"], + "speed": "200", + }, + ) + process = write_profile(root, "process", "process.json", {"name": "process"}) + filament = write_profile(root, "filament", "filament.json", {"name": "filament"}) + + result = BambuProfileStore(root).materialize( + machine=machine, + process=process, + filaments=(filament,), + destination=tmp_path / "materialized", + ) + + payload = json.loads(result.machine.read_text(encoding="utf-8")) + assert payload["name"] == "leaf" + assert payload["speed"] == "200" + assert payload["gcode"] == "safe" + assert payload["from"] == "User" + assert payload["inherits"] == "leaf" + assert len(result.filaments) == 1 + + +def test_materializer_rejects_unresolved_parent(tmp_path: Path) -> None: + root = tmp_path / "profiles" + machine = write_profile( + root, + "machine", + "machine.json", + {"name": "machine", "inherits": "missing"}, + ) + process = write_profile(root, "process", "process.json", {"name": "process"}) + filament = write_profile(root, "filament", "filament.json", {"name": "filament"}) + + with pytest.raises(ValueError, match="unresolved"): + BambuProfileStore(root).materialize( + machine=machine, + process=process, + filaments=(filament,), + destination=tmp_path / "materialized", + ) + + +def test_materializer_rejects_source_outside_tree(tmp_path: Path) -> None: + root = tmp_path / "profiles" + write_profile(root, "machine", "machine.json", {"name": "machine"}) + process = write_profile(root, "process", "process.json", {"name": "process"}) + filament = write_profile(root, "filament", "filament.json", {"name": "filament"}) + outside = tmp_path / "outside.json" + outside.write_text('{"name": "outside"}', encoding="utf-8") + + with pytest.raises(ValueError, match="outside"): + BambuProfileStore(root).materialize( + machine=outside, + process=process, + filaments=(filament,), + destination=tmp_path / "materialized", + ) diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..54158db --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,261 @@ +import json +import textwrap +from pathlib import Path + +import pytest + +from openprintbench.models import FixtureProvenance, ProfileProvenance, SlicerProbe +from openprintbench.run import execute_bambu_slice +from openprintbench.slicers.bambu import BambuSliceRequest + +SOURCE_COMMIT = "a" * 40 +SOURCE_URL = ( + f"https://github.com/kforris/OpenPrintBench/blob/{SOURCE_COMMIT}/fixtures/cube-20mm.stl" +) +PROFILE_COMMIT = "b" * 40 + + +def make_fake_slicer(path: Path, *, exit_status: int = 0, sleep_seconds: float = 0) -> Path: + path.write_text( + textwrap.dedent( + f"""\ + #!/usr/bin/env python3 + import pathlib + import sys + import time + + time.sleep({sleep_seconds}) + output_dir = pathlib.Path(sys.argv[sys.argv.index("--outputdir") + 1]) + output_name = sys.argv[sys.argv.index("--export-3mf") + 1] + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / output_name).write_bytes(b"deterministic fake slice") + print("/Users/example/private token=visible access_code=1234") + print("stderr password=hunter2", file=sys.stderr) + raise SystemExit({exit_status}) + """ + ), + encoding="utf-8", + ) + path.chmod(0o755) + return path + + +def available_probe(executable: Path) -> SlicerProbe: + return SlicerProbe( + slicer="bambu", + display_name="Bambu Studio", + available=True, + executable=str(executable.resolve()), + version="02.06.00.51", + source="explicit", + ) + + +def provenance() -> FixtureProvenance: + return FixtureProvenance( + source_url=SOURCE_URL, + source_commit=SOURCE_COMMIT, + license="CC0-1.0", + ) + + +def profile_provenance() -> ProfileProvenance: + return ProfileProvenance( + source_url=f"https://github.com/bambulab/BambuStudio/tree/{PROFILE_COMMIT}/profiles", + source_commit=PROFILE_COMMIT, + license="AGPL-3.0-only", + ) + + +def stl_request(tmp_path: Path, executable: Path) -> BambuSliceRequest: + source = tmp_path / "cube-20mm.stl" + source.write_text("solid cube\nendsolid cube\n", encoding="utf-8") + profile_root = tmp_path / "profiles" + machine = profile_root / "machine" / "machine.json" + process = profile_root / "process" / "process.json" + filament = profile_root / "filament" / "filament.json" + for path, category in ( + (machine, "machine"), + (process, "process"), + (filament, "filament"), + ): + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"type": category, "name": f"test-{category}"}), + encoding="utf-8", + ) + return BambuSliceRequest( + executable=executable, + input_path=source, + output_dir=tmp_path / "unused", + machine_settings=machine, + process_settings=process, + filament_settings=(filament,), + ) + + +def profile_kwargs(tmp_path: Path) -> dict[str, object]: + return { + "profile_root": tmp_path / "profiles", + "profile_provenance": profile_provenance(), + } + + +def test_success_writes_portable_manifest_and_redacted_log(tmp_path: Path) -> None: + executable = make_fake_slicer(tmp_path / "BambuStudio") + request = stl_request(tmp_path, executable) + run_dir = tmp_path / "run" + + evidence = execute_bambu_slice( + request, + available_probe(executable), + run_dir=run_dir, + provenance=provenance(), + approved=True, + **profile_kwargs(tmp_path), + ) + + assert evidence.succeeded is True + assert evidence.output_count == 1 + assert run_dir.stat().st_mode & 0o777 == 0o700 + manifest = json.loads(Path(evidence.manifest_path).read_text(encoding="utf-8")) + serialized = json.dumps(manifest) + assert manifest["state"] == "executed" + assert manifest["fixture"]["source_commit"] == SOURCE_COMMIT + assert manifest["fixture"]["license"] == "CC0-1.0" + assert manifest["settings"]["materializer"] == "openprintbench-bambu-profile-merge-v1" + assert manifest["settings"]["provenance"]["source_commit"] == PROFILE_COMMIT + assert manifest["settings"]["sources"]["machine"]["name"] == "machine.json" + assert manifest["settings"]["materialized"]["machine"]["name"] == "machine.json" + assert manifest["exit_status"] == 0 + assert manifest["timed_out"] is False + assert manifest["physical_validation"] is None + assert manifest["outputs"][0]["relative_path"] == "sliced.3mf" + assert "${SLICER}" in manifest["command"] + assert "${INPUT}" in manifest["command"] + assert "${OUTPUT_DIR}" in manifest["command"] + portable_command = " ".join(manifest["command"]) + assert "${MACHINE_SETTINGS}" in portable_command + assert "${PROCESS_SETTINGS}" in portable_command + assert "${FILAMENT_SETTINGS_1}" in portable_command + assert str(tmp_path) not in serialized + log = Path(evidence.log_path).read_text(encoding="utf-8") + assert "/Users/example" not in log + assert "visible" not in log + assert "hunter2" not in log + assert log.count("") == 3 + + +def test_approval_is_required_before_run_directory_creation(tmp_path: Path) -> None: + executable = make_fake_slicer(tmp_path / "BambuStudio") + run_dir = tmp_path / "run" + + with pytest.raises(ValueError, match="explicit approval"): + execute_bambu_slice( + stl_request(tmp_path, executable), + available_probe(executable), + run_dir=run_dir, + provenance=provenance(), + approved=False, + ) + + assert not run_dir.exists() + + +def test_existing_run_directory_is_rejected(tmp_path: Path) -> None: + executable = make_fake_slicer(tmp_path / "BambuStudio") + run_dir = tmp_path / "run" + run_dir.mkdir() + + with pytest.raises(ValueError, match="must not already exist"): + execute_bambu_slice( + stl_request(tmp_path, executable), + available_probe(executable), + run_dir=run_dir, + provenance=provenance(), + approved=True, + **profile_kwargs(tmp_path), + ) + + +def test_nonzero_exit_still_writes_evidence(tmp_path: Path) -> None: + executable = make_fake_slicer(tmp_path / "BambuStudio", exit_status=7) + + evidence = execute_bambu_slice( + stl_request(tmp_path, executable), + available_probe(executable), + run_dir=tmp_path / "run", + provenance=provenance(), + approved=True, + **profile_kwargs(tmp_path), + ) + + manifest = json.loads(Path(evidence.manifest_path).read_text(encoding="utf-8")) + assert evidence.succeeded is False + assert evidence.exit_status == 7 + assert manifest["exit_status"] == 7 + assert manifest["outputs"] + + +def test_timeout_is_recorded_without_an_exit_status(tmp_path: Path) -> None: + executable = make_fake_slicer(tmp_path / "BambuStudio", sleep_seconds=0.2) + + evidence = execute_bambu_slice( + stl_request(tmp_path, executable), + available_probe(executable), + run_dir=tmp_path / "run", + provenance=provenance(), + approved=True, + timeout_seconds=0.01, + **profile_kwargs(tmp_path), + ) + + manifest = json.loads(Path(evidence.manifest_path).read_text(encoding="utf-8")) + assert evidence.succeeded is False + assert evidence.timed_out is True + assert manifest["timed_out"] is True + assert manifest["exit_status"] is None + + +@pytest.mark.parametrize( + "source_commit", + ["short", "z" * 40], +) +def test_provenance_requires_full_git_sha(tmp_path: Path, source_commit: str) -> None: + executable = make_fake_slicer(tmp_path / "BambuStudio") + + with pytest.raises(ValueError, match="full 40-character"): + execute_bambu_slice( + stl_request(tmp_path, executable), + available_probe(executable), + run_dir=tmp_path / "run", + provenance=FixtureProvenance(SOURCE_URL, source_commit, "CC0-1.0"), + approved=True, + ) + + +def test_timeout_is_bounded(tmp_path: Path) -> None: + executable = make_fake_slicer(tmp_path / "BambuStudio") + + with pytest.raises(ValueError, match="timeout"): + execute_bambu_slice( + stl_request(tmp_path, executable), + available_probe(executable), + run_dir=tmp_path / "run", + provenance=provenance(), + approved=True, + timeout_seconds=0, + ) + + +def test_stl_execution_requires_profile_provenance(tmp_path: Path) -> None: + executable = make_fake_slicer(tmp_path / "BambuStudio") + + with pytest.raises(ValueError, match="profile root"): + execute_bambu_slice( + stl_request(tmp_path, executable), + available_probe(executable), + run_dir=tmp_path / "run", + provenance=provenance(), + approved=True, + )