fix(tooling): secure cumulative mosaic patch export

This commit is contained in:
fy59 2026-10-11 07:37:35 +02:00
parent 8e8d1a819b
commit 76a765a996
6 changed files with 420 additions and 9 deletions

142
AGENTS.md Normal file
View file

@ -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.

View file

@ -2,6 +2,11 @@
## Unreleased ## 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 ### Documentation
- Established a public landing page, original source-controlled SVG banner, canonical documentation index, UX contract, packaging contract, known limitations, and documentation-first roadmap. - Established a public landing page, original source-controlled SVG banner, canonical documentation index, UX contract, packaging contract, known limitations, and documentation-first roadmap.

View file

@ -59,7 +59,41 @@ Inspect the staging manifest. With `mosaic-package=true`, the expected installed
## Patch export ## 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 ## Upstream update branch

View file

@ -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`. 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 ## Arch package expectations

175
tests/test_export_patch.py Normal file
View file

@ -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()

View file

@ -1,13 +1,68 @@
#!/bin/sh #!/bin/sh
# Rebuild the distributable delta with a private Git index; the real index and # CONTRACT: export the complete Mosaic source delta from the verified SwayFX 0.6
# upstream commit stay untouched. Run only after validating this source tree. # import. The private index includes staged, unstaged and untracked source,
# while the caller's real index and unrelated project files remain untouched.
set -eu set -eu
cd "$(dirname "$0")/.." 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) 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" 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 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 \ git diff --cached --binary --full-index "$base" -- \
> patches/0001-mosaic-layout-viewport.patch include meson.build meson_options.txt sway tests/mosaic-model.c > "$candidate"
git apply --check --reverse patches/0001-mosaic-layout-viewport.patch 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=