From 76a765a9965815a52586cc10e56e9d99f2720bcb Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 11 Oct 2026 07:37:35 +0200 Subject: [PATCH] fix(tooling): secure cumulative mosaic patch export --- AGENTS.md | 142 ++++++++++++++++++++++++++++++ CHANGELOG.md | 5 ++ docs/DEVELOPMENT.md | 36 +++++++- docs/PACKAGING.md | 2 +- tests/test_export_patch.py | 175 +++++++++++++++++++++++++++++++++++++ tooling/export-patch.sh | 69 +++++++++++++-- 6 files changed, 420 insertions(+), 9 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/test_export_patch.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9e094eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,142 @@ +# Codex instructions — SwayFX Mosaic + +These instructions apply to the entire repository. They define the agent's +working discipline, not the product specification. If this file conflicts with +canonical project documentation, stop and report the conflict; do not silently +choose a convenient interpretation. + +## Mission and communication + +- Develop **only** the optional native Mosaic / Spatial Canvas layout for SwayFX. + Do not turn the project into a general-purpose SwayFX fork. +- Explain plans, findings, test evidence and blockers to the user **in French**. + Follow the existing language/style of each source file and document. +- Be evidence-first. Distinguish **CURRENT**, **EXPERIMENTAL**, **PLANNED**, + **NOT TESTED**, **BLOCKED**, and **NOT YET SUPPORTED**. Never upgrade a status + based on a proposed design, a private spike or a successful compile alone. +- Work within the requested scope. Investigations and design proposals do not + authorize production implementation, packaging changes or live deployment. + +## Read before changing anything + +1. `git status --short --branch`, current branch, recent commits and diff. + Preserve pre-existing user changes; do not reset, stash or overwrite them. +2. `docs/README.md` (documentation authority and index). +3. `docs/ARCHITECTURE.md`, `docs/UX.md`, + `docs/KNOWN_LIMITATIONS.md`, and `docs/ROADMAP.md`. +4. For build/source work: `docs/DEVELOPMENT.md` and `docs/UPSTREAM.md`. + For packaging: `docs/PACKAGING.md`. +5. For STEP20C: all four reports under `docs/design/STEP20C_*.md`, + especially `STEP20C_POPUP_OWNERSHIP_PROBE.md`. + +Canonical architecture/UX/packaging/upstream/development documents govern their +own topics. Design documents are proposals or isolated evidence; historical +`docs/STEP20A.md` and `docs/STEP20B.md` are not current specifications. +Resolve contradictory canonical documents **before** implementing behavior. + +## Architecture and user-experience invariants + +- SwayFX's real compositor and tiling tree own runtime order, focus, geometry, + camera, transactions, scene, hit testing and IPC. Do not emulate tiling with + floating windows or give QuickShell, Session V2 or a daemon parallel authority. +- Mosaic is opt-in **inside an ordinary Sway workspace**. Preserve standard + layouts and the workspace-local `layout toggle mosaic` restoration path. +- Runtime Row 0 **borrows** `workspace->tiling`; Mosaic state does not own Sway + containers. Additional rows currently exist only in the model, not runtime. +- Keep logical row order, geometry, viewport/camera and focus distinct. + Keep applied rendering and input consistent with the same transaction state. +- Super alone must never move windows. Intended external bindings are + Super+Alt+L (standard/Mosaic) and Super+Alt+M (Applications); this repository + must not install personal bindings, dotfiles or QuickShell configuration. +- Preserve ordinary SwayFX floating, fullscreen, scratchpad, popup and IPC + behavior unless an explicitly documented, tested Mosaic-specific contract + requires a change. Do not invent final cell sizes or animation policy. + +## Current boundary — STEP20C is a feasibility gate + +- **STEP20A: DONE. STEP20B: EXPERIMENTAL. STEP20C: NEXT, not implemented in + production. STEP20D–G: PLANNED.** Recheck the roadmap on every new task. +- Cross-output viewport clipping is **NOT YET SUPPORTED**; Mosaic is **not** + validated as a daily-driver compositor. Do not claim otherwise. +- The isolated SceneFX subtree clip covered sampled tiled visual/input cases. + A later isolated owner-derived root-popup clip covered sampled GTK root and + nested popup cases on two horizontal headless outputs. Both are only + **PARTIALLY_FEASIBLE**, not a production dependency decision. +- Next evidence required: reproducible **valid-serial XDG grabs** (distinguish + popup dismissal/grab behavior from invisible scene hits), **fully hidden + popup owner**, vertical/negative-origin/scaled/rotated outputs, output + migration/resize, transaction-generation consistency, damage/stale pixels, + decorations/shadows/blur/saved buffers, and SceneFX/wlroots ABI/package + maintenance. Mark any untested matrix cell **NOT TESTED**. +- For popup semantics, compare the modified private compositor against an + unmodified headless SwayFX control. Do not promise universal click-through. + Offscreen-popup hide/dismiss/reposition UX remains undecided. +- Do not merge private SceneFX experiments into the project, create a permanent + library fork, alter public ABI, add package dependencies, or claim STEP20C + production readiness without an explicit decision and closed feasibility + gates. Use disposable private checkouts, libraries, sockets and logs for + bounded probes. + +## Safe development workflow + +1. State the exact scope, accepted contract, expected files and test plan. + Prefer one small, reviewable change at a time. +2. **Docs first:** change `docs/UX.md` for user-visible behavior and + `docs/ARCHITECTURE.md` for architecture **before** implementation. Update + Packaging/Upstream/Development when those contracts change; update Known + Limitations/Roadmap with actual evidence. Never present a proposal as shipped. +3. Use existing C, Sway/wlroots/SceneFX conventions. Keep ownership/lifetimes, + container identity, popup hierarchy, pending/current state and output + coordinates explicit. Add regression tests for changed behavior. +4. Build and test in isolated build directories; never install, reload, + restart or replace the **running desktop session**. Never operate on the + live Sway IPC socket. Headless tests must use their own `XDG_RUNTIME_DIR`, + `WAYLAND_DISPLAY` and explicit `SWAYSOCK` / `swaymsg -s`. +5. For a normal source change, verify the documented sequence as applicable: + + ```sh + meson compile -C build-mosaic -j 8 + meson test -C build-mosaic --print-errorlogs + MOSAIC_BINARY="$PWD/build-mosaic/sway/swayfx-mosaic" python tests/headless-ipc.py + git diff --check + ``` + + If the build directory does not exist, set it up **as documented** in + `docs/DEVELOPMENT.md`. Do not rebuild a supposed upstream baseline in a + Mosaic-modified checkout. For documentation-only changes, validate links, + consistency, and `git diff --check`; do not claim runtime tests were run. +6. For packaging tasks, use **DESTDIR staging only** and verify the complete + manifest contains exactly `/usr/bin/swayfx-mosaic`. No live install, sudo, + `pacman`, session switch or system library replacement without explicit + user authorization. + +## Package, patch and upstream safety + +- Pinned base: SwayFX `0.6` at + `fd71a6bdc061bd633b488ae7b83e8a6981d22f86`, + wlroots 0.20 and SceneFX 0.5. Never assume an upstream update is compatible. +- The ordinary SwayFX package owns `/usr/bin/sway`; this project may stage + only `/usr/bin/swayfx-mosaic`. No `provides`, `conflicts` or `replaces` + against sway/SwayFX. Do not alter or block normal SwayFX upgrades. +- Remotes: `origin` = canonical Forgejo; `github` = public mirror; + `upstream` = SwayFX fetch-only. Never push Mosaic to `upstream`. +- Production patch: `patches/0001-mosaic-layout-viewport.patch`, intended as + the **complete cumulative delta** against the pinned pristine upstream + commit. Verify forward application on a clean pinned checkout, source + equivalence, and the `PKGBUILD` checksum before treating it as releasable. +- `tooling/export-patch.sh` builds the cumulative delta from the verified + pristine SwayFX 0.6 import in a private index. Run its offline regression + test, export, review the patch, verify forward application and source + equivalence on a clean pinned checkout, then check/update the `PKGBUILD` + checksum. The exporter validates before atomic replacement and must leave + the previous artifact intact on failure. Never force-apply hunks. +- Do not modify git history, branches, remotes or package version/pins without + an explicit scoped reason. **No commit or push unless the user requests it.** + +## Required handoff after each task + +Report in French: files changed, exact behavior or finding, commands run and +PASS/FAIL/NOT RUN results, limitations/risks, and whether anything was +committed/pushed. Keep the working tree visible and identify the next +**smallest justified step**. Never conceal failed tests or silently broaden +the task into STEP20C production work. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd401d..dcf12de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Patch export tooling + +- Fixed cumulative Mosaic patch export to diff against the verified pristine SwayFX 0.6 import, preserve the real Git index, validate clean-base application and source equivalence, and replace the patch atomically only after successful checks. +- Added offline regression coverage for complete and repeatable export, working and staged edits, source additions/deletions and mode changes, index preservation, conflict refusal, and artifact preservation on failure or interruption. + ### Documentation - Established a public landing page, original source-controlled SVG banner, canonical documentation index, UX contract, packaging contract, known limitations, and documentation-first roadmap. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4da9b89..c2a8c05 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -59,7 +59,41 @@ Inspect the staging manifest. With `mosaic-package=true`, the expected installed ## Patch export -The development tree contains full SwayFX source for editing. `patches/0001-mosaic-layout-viewport.patch` is the production delta against the pinned base. After source changes, use `tooling/export-patch.sh` to regenerate it, then verify the patch against a **clean checkout of the pinned upstream commit** with `git apply --check`. Review the patch, refresh its package checksum, and validate the package build. `git apply --check` in an already-patched development tree is not a valid forward-application check. +The development tree contains full SwayFX source for editing. `patches/0001-mosaic-layout-viewport.patch` is the complete production delta against the imported pristine SwayFX 0.6 commit `a57e3f2c4d88f258c2f038343a398c6d5be1fb59` (whose relevant source matches pinned upstream `fd71a6bdc061bd633b488ae7b83e8a6981d22f86`). Run the regression test before export: + +```sh +PYTHONDONTWRITEBYTECODE=1 python -m unittest -v tests/test_export_patch.py +sh -n tooling/export-patch.sh +tooling/export-patch.sh +sha256sum patches/0001-mosaic-layout-viewport.patch +git diff --check +``` + +The exporter uses a separate temporary Git index and object directory and includes staged, unstaged, added and deleted package source under `include/`, `sway/`, `meson.build`, `meson_options.txt` and `tests/mosaic-model.c`. It refuses unresolved conflicts in those paths and does not change the real index or object database. Before atomic replacement, it requires a nonempty patch, reverse applicability to the current tree, forward applicability to a pristine archive of the imported base, and equivalence of source contents, file modes and links. Failure leaves the prior patch intact. Review the resulting patch and update the checksum in `PKGBUILD` whenever its bytes change. + +For an independent release check when the pinned upstream commit is available locally, use a disposable archive of that exact commit: + +```sh +( + set -eu + upstream=fd71a6bdc061bd633b488ae7b83e8a6981d22f86 + imported=a57e3f2c4d88f258c2f038343a398c6d5be1fb59 + patch_file="$PWD/patches/0001-mosaic-layout-viewport.patch" + git diff --exit-code "$upstream" "$imported" -- \ + include meson.build meson_options.txt sway tests/mosaic-model.c + pristine=$(mktemp -d) + trap 'rm -rf "$pristine"' 0 + git archive "$upstream" > "$pristine/base.tar" + mkdir "$pristine/source" + tar -xf "$pristine/base.tar" -C "$pristine/source" + (cd "$pristine/source" && git apply --check "$patch_file" && git apply "$patch_file") + for source in include sway meson.build meson_options.txt tests/mosaic-model.c; do + git diff --no-index --exit-code -- "$pristine/source/$source" "$source" + done +) +``` + +Then review the patch and run the package checks in [Packaging](PACKAGING.md). `git apply --check` in an already-patched development tree is not a valid forward-application check. These exporter checks do not qualify the package without a full `makepkg` build and staged manifest inspection. ## Upstream update branch diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 3a25255..dfd19c8 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -25,7 +25,7 @@ The isolated [SceneFX subtree clipping spike](design/STEP20C_SCENEFX_CLIPPING_SP Versions identify the SwayFX base and Mosaic revision, for example `swayfx 0.6` and `swayfx-mosaic 0.6+mosaic.1`. The current package pins SwayFX tag `0.6` at `fd71a6bdc061bd633b488ae7b83e8a6981d22f86`, then applies `patches/0001-mosaic-layout-viewport.patch`. The recipe checks the fetched commit and patch applicability before building. An upstream `0.7` release does not make the current patch valid for `0.7`. -The repository contains full source for development. Patch export provides a reviewable production delta. A patch must match its pinned upstream commit and recorded checksum; forced or silent patch application is forbidden. +The repository contains full source for development. The exporter constructs the cumulative patch from the verified pristine SwayFX 0.6 import, checks forward and reverse application and source equivalence, then atomically replaces the artifact. A patch must match its pinned upstream commit and recorded `PKGBUILD` checksum; forced or silent patch application is forbidden. See the exact offline export and independent clean-source verification procedure in [Development](DEVELOPMENT.md). Successful export alone is not package qualification. ## Arch package expectations diff --git a/tests/test_export_patch.py b/tests/test_export_patch.py new file mode 100644 index 0000000..ddaa907 --- /dev/null +++ b/tests/test_export_patch.py @@ -0,0 +1,175 @@ +"""Offline regression checks for the cumulative package patch exporter.""" + +import hashlib +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +PATCH = Path("patches/0001-mosaic-layout-viewport.patch") +BASE = "a57e3f2c4d88f258c2f038343a398c6d5be1fb59" + + +def run(*args, cwd, env=None, check=True): + return subprocess.run( + args, cwd=cwd, env=env, check=check, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +class ExportPatchTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="mosaic-export-test-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.clone = self.root / "clone" + # The clone retains the imported base but never changes the real repo. + run("git", "clone", "-q", "--no-hardlinks", str(ROOT), str(self.clone), cwd=self.root) + shutil.copy2(ROOT / "tooling/export-patch.sh", self.clone / "tooling/export-patch.sh") + shutil.copy2(ROOT / PATCH, self.clone / PATCH) + + def export(self, *, env=None, check=True): + return run("sh", "tooling/export-patch.sh", cwd=self.clone, env=env, check=check) + + def test_complete_idempotent_and_index_untouched(self): + original = (self.clone / PATCH).read_bytes() + self.export() + first = (self.clone / PATCH).read_bytes() + self.assertTrue(first) + self.assertEqual(first, original) + self.export() + self.assertEqual((self.clone / PATCH).read_bytes(), first) + + # One staged source edit plus one later working-tree edit must both + # appear, while the real staged index remains byte-identical. + source = self.clone / "sway/tree/mosaic.c" + with source.open("a") as file: + file.write("\n/* export staged marker */\n") + run("git", "add", "sway/tree/mosaic.c", cwd=self.clone) + with source.open("a") as file: + file.write("/* export working marker */\n") + index = self.clone / ".git/index" + index_before = index.read_bytes() + objects_before = sorted(str(path.relative_to(self.clone / ".git/objects")) + for path in (self.clone / ".git/objects").rglob("*") + if path.is_file()) + staged_before = run("git", "diff", "--cached", "--binary", cwd=self.clone).stdout + self.export() + changed = (self.clone / PATCH).read_bytes() + self.assertIn(b"export staged marker", changed) + self.assertIn(b"export working marker", changed) + self.assertEqual(index.read_bytes(), index_before) + self.assertEqual(sorted(str(path.relative_to(self.clone / ".git/objects")) + for path in (self.clone / ".git/objects").rglob("*") + if path.is_file()), objects_before) + self.assertEqual(run("git", "diff", "--cached", "--binary", cwd=self.clone).stdout, + staged_before) + self.export() + self.assertEqual((self.clone / PATCH).read_bytes(), changed) + + def test_untracked_source_and_failure_preserve_patch(self): + source = self.clone / "include/sway/tree/export-probe.h" + source.write_text("/* untracked source marker */\n") + self.export() + exported = (self.clone / PATCH).read_bytes() + self.assertIn(b"untracked source marker", exported) + + # Ignored files cannot be exported by Git. Source equivalence must + # reject this late validation failure before replacing the artifact. + ignored = self.clone / "include/sway/tree/export-probe.o" + ignored.write_text("ignored source marker\n") + failed = self.export(check=False) + self.assertNotEqual(failed.returncode, 0) + self.assertEqual(digest((self.clone / PATCH).read_bytes()), digest(exported)) + + # Also cover an early temporary-allocation failure. + env = os.environ.copy() + env["TMPDIR"] = str(self.root / "missing") + failed = self.export(env=env, check=False) + self.assertNotEqual(failed.returncode, 0) + self.assertEqual(digest((self.clone / PATCH).read_bytes()), digest(exported)) + + def test_source_deletion_is_exported(self): + source = self.clone / "include/sway/tree/node.h" + source.unlink() + self.export() + patch = (self.clone / PATCH).read_bytes() + self.assertIn(b"deleted file mode", patch) + self.assertIn(b"include/sway/tree/node.h", patch) + + def test_unmerged_source_index_preserves_patch(self): + original = (self.clone / PATCH).read_bytes() + path = "include/sway/tree/container.h" + blob = run("git", "rev-parse", f"HEAD:{path}", cwd=self.clone).stdout.strip() + run("git", "update-index", "--force-remove", path, cwd=self.clone) + subprocess.run( + ["git", "update-index", "--index-info"], cwd=self.clone, + input=f"100644 {blob} 1\t{path}\n100644 {blob} 2\t{path}\n", + text=True, check=True, capture_output=True, + ) + index = self.clone / ".git/index" + index_before = index.read_bytes() + failed = self.export(check=False) + self.assertNotEqual(failed.returncode, 0) + self.assertIn("unresolved source conflicts", failed.stderr) + self.assertEqual(index.read_bytes(), index_before) + self.assertEqual((self.clone / PATCH).read_bytes(), original) + + def test_source_mode_change_is_exported(self): + source = self.clone / "sway/tree/mosaic.c" + source.chmod(0o755) + self.export() + patch = (self.clone / PATCH).read_bytes() + self.assertIn(b"new file mode 100755", patch) + self.assertEqual(source.stat().st_mode & 0o777, 0o755) + + def test_interruption_preserves_patch_and_cleans_temporary_files(self): + original = (self.clone / PATCH).read_bytes() + tool_dir = self.root / "tools" + tool_dir.mkdir() + wrapper = tool_dir / "git" + wrapper.write_text( + "#!/bin/sh\n" + "if test \"$1\" = archive; then kill -TERM \"$PPID\"; exit 1; fi\n" + f"exec {shlex.quote(shutil.which('git'))} \"$@\"\n" + ) + wrapper.chmod(0o755) + scratch = self.root / "scratch" + scratch.mkdir() + env = os.environ.copy() + env["PATH"] = f"{tool_dir}:{env['PATH']}" + env["TMPDIR"] = str(scratch) + failed = self.export(env=env, check=False) + self.assertNotEqual(failed.returncode, 0) + self.assertEqual((self.clone / PATCH).read_bytes(), original) + self.assertEqual(list((self.clone / "patches").glob(".mosaic-patch.*")), []) + self.assertEqual(list(scratch.iterdir()), []) + + def test_forward_apply_and_source_equivalence(self): + self.export() + pristine = self.root / "pristine" + pristine.mkdir() + archive = self.root / "base.tar" + with archive.open("wb") as file: + subprocess.run(["git", "archive", BASE], cwd=self.clone, + check=True, stdout=file) + run("tar", "-xf", str(archive), "-C", str(pristine), cwd=self.root) + run("git", "apply", "--check", str(self.clone / PATCH), cwd=pristine) + run("git", "apply", str(self.clone / PATCH), cwd=pristine) + for name in ("include", "sway", "meson.build", "meson_options.txt", + "tests/mosaic-model.c"): + run("git", "diff", "--no-index", "--exit-code", "--", + str(pristine / name), str(self.clone / name), cwd=self.root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tooling/export-patch.sh b/tooling/export-patch.sh index 8356664..4519011 100755 --- a/tooling/export-patch.sh +++ b/tooling/export-patch.sh @@ -1,13 +1,68 @@ #!/bin/sh -# Rebuild the distributable delta with a private Git index; the real index and -# upstream commit stay untouched. Run only after validating this source tree. +# CONTRACT: export the complete Mosaic source delta from the verified SwayFX 0.6 +# import. The private index includes staged, unstaged and untracked source, +# while the caller's real index and unrelated project files remain untouched. set -eu cd "$(dirname "$0")/.." +repository=$(pwd -P) +base=a57e3f2c4d88f258c2f038343a398c6d5be1fb59 +patch=patches/0001-mosaic-layout-viewport.patch + +# INVARIANT: a different history must not replace the production artifact +# with a partial delta. +test "$(git rev-parse --show-toplevel)" = "$repository" +test "$(git rev-parse --verify "${base}^{commit}")" = "$base" +git merge-base --is-ancestor "$base" HEAD +# CONTRACT: an unresolved merge has no unambiguous source state to publish. +# Check the caller's index before switching Git to the private one. +if test -n "$(git ls-files -u -- include meson.build meson_options.txt sway tests/mosaic-model.c)"; then + echo 'Cannot export with unresolved source conflicts' >&2 + exit 1 +fi + temporary=$(mktemp -d) -trap 'rm -rf "$temporary"' EXIT HUP INT TERM +candidate= +cleanup() { + rm -rf "$temporary" + if test -n "$candidate"; then + rm -f "$candidate" + fi +} +trap cleanup 0 +trap 'exit 1' 1 2 3 15 +candidate=$(mktemp "$repository/patches/.mosaic-patch.XXXXXX") + +# Git add may need to create blobs for working-tree edits. Keep those objects +# private too; the repository object database is only an alternate read source. +original_objects=$(git rev-parse --path-format=absolute --git-path objects) +mkdir "$temporary/objects" +export GIT_OBJECT_DIRECTORY="$temporary/objects" +export GIT_ALTERNATE_OBJECT_DIRECTORIES="$original_objects" export GIT_INDEX_FILE="$temporary/index" -git read-tree HEAD +git read-tree "$base" +# Only package source belongs in the patch. -A includes additions, removals, +# staged changes and working-tree edits without changing the caller's index. git add -A -- include meson.build meson_options.txt sway tests/mosaic-model.c -git diff --cached --binary --full-index -- include meson.build meson_options.txt sway tests/mosaic-model.c \ - > patches/0001-mosaic-layout-viewport.patch -git apply --check --reverse patches/0001-mosaic-layout-viewport.patch +git diff --cached --binary --full-index "$base" -- \ + include meson.build meson_options.txt sway tests/mosaic-model.c > "$candidate" +test -s "$candidate" + +# Reverse applicability checks the current tree. Forward application and a +# source content and mode comparison catch incomplete exports before publication. +git apply --check --reverse "$candidate" +mkdir "$temporary/pristine" +git archive "$base" > "$temporary/base.tar" +tar -xf "$temporary/base.tar" -C "$temporary/pristine" +( + cd "$temporary/pristine" + git apply --check "$candidate" + git apply "$candidate" +) +for source in include sway meson.build meson_options.txt tests/mosaic-model.c; do + git diff --no-index --exit-code -- "$temporary/pristine/$source" "$source" +done + +# candidate lives beside patch, so rename is atomic on the same filesystem. +chmod 644 "$candidate" +mv -f "$candidate" "$patch" +candidate=