Skip to content

Describe a change by what the run saw on both sides - #221

Merged
congwang-mk merged 12 commits into
mainfrom
change-set-origins
Sep 11, 2026
Merged

Describe a change by what the run saw on both sides#221
congwang-mk merged 12 commits into
mainfrom
change-set-origins

Conversation

@congwang-mk

Copy link
Copy Markdown
Contributor

Summary

Result.changes used to carry a kind and a path, and the kind was derived by looking at the live workdir when the report was read. A caller reviewing a deferred run had to re-read both trees to learn what changed, could not tell a chmod from a rewrite, could not pair a rename, and saw a file that appeared underneath after the run turn a creation into an overwrite.

A Change now carries an Entry for each side, the workdir entry the run first saw and the branch entry it left, with kind, mode, size, a SHA-256 digest for files, and the target for symlinks. Added, Modified, and Deleted are derived from which sides exist. content_unchanged, type_changed, and a renames digest join answer the questions a reviewer asks.

How the before side is captured

  • Every mutating handler records the workdir entry at first touch: one confined lstat, no bytes read, kept in an in-memory map next to the whiteout set.
  • A copied-up file's digest is computed inside the copy stream, off the COW lock, and handed back once the copy lands. Hashing under the lock would reintroduce the stall the two-phase copy exists to avoid.
  • A file unlinked without a copy-up is hashed at capture from the workdir, which the commit has not touched yet, guarded by its recorded size.
  • Nothing is written to disk during the run. The records ride in the PRESERVED marker, which is already written once and atomically, so a kept or preserved branch reports the same change set a live run does. A supervisor killed before it preserves loses the origins and the report degrades to unknown before sides.
  • A whiteout over an entry the confined lstat could not reach is still reported, as Deleted with no before side, so an obstructed deletion stays visible after a failed merge.

API

  • Core: Entry, EntryKind, two-sided Change with kind() as a method, renames. Change.kind stops being a field, no shim.
  • FFI: sandlock_entry_t plus sandlock_result_change_entry and sandlock_result_change_target. Existing accessors keep their signatures. Header regenerated.
  • Python and Go: Entry, the new Change, and renames / Renames. kind stays a string property in Python so existing examples keep printing A out.txt.

Ring, already compiled through the rustls features, becomes a direct dependency of core for SHA-256.

Testing

  • Core: unit tests for the derived kind and helpers, the origin map, each handler recording once, the copy-stream digest, a renamed directory expanding to per-entry changes that pair up, chmod-only, symlink retarget, marker round trips including a target with a tab and a newline, and a marker without origin lines.
  • FFI, Python, and Go each round-trip an M, an A, a D, and a symlink target.
  • Full Python suite and full Go suite pass. The full Rust workspace passes except two pre-existing machine-local failures that fail identically on main.

A Change carried a kind and a path, and the kind was the only thing it
knew about either side. A caller reviewing a deferred run then had to
re-read the workdir and the upper to learn what changed, and could not
tell a chmod from a rewrite or pair a rename. The change now carries an
Entry for each side (kind, mode, size, digest for files, target for
links) and derives Added, Modified, or Deleted from which sides exist.
Helpers cover the questions a reviewer asks: content unchanged, type
changed, and a digest join that pairs renames.

Both sides are still read from the live trees; the digests stay empty
until the branch records what it saw at first touch.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The change set can only say what the run replaced if something remembered
the workdir entry before the branch touched it. This map holds that, keyed
by relative path, first record wins, nothing on disk: losing it degrades a
report from Modified to Added and nothing else, which does not justify an
fsync in the syscall path the way a lost whiteout does.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The change set labelled a path by looking at the live workdir when the
report was read, so a file that appeared underneath after the run turned a
creation into an overwrite, and a report could never carry the old digest.
Every handler that mutates a path now records the workdir entry at first
touch, and the report is computed from that record and the upper alone.

A copied-up file's digest is taken inside the copy stream, off the COW
lock, and handed back once the copy lands: hashing under the lock would
reintroduce the stall the two-phase copy exists to avoid. A file unlinked
without a copy-up is hashed at capture from the workdir, which the commit
has not touched yet, guarded by its recorded size.

A whiteout over an entry the confined lstat could not reach keeps being
reported, with an unknown before side, so an obstructed deletion still
shows as outstanding after a failed merge.

Signed-off-by: Cong Wang <cwang@multikernel.io>
A kept or preserved branch outlives the process that ran it, and with it
the origin map, so recovery tooling could only report additions and bare
deletions. The marker already lists the outstanding deletions and is
written once, atomically, off the syscall path; it now also holds one line
per origin record, and a preserved branch reports the same change set a
live run does through the same computation.

Nothing is written during the run. A supervisor killed before it preserves
loses the origins and the report degrades to unknown before sides, which
is the trade for a run that pays no fsync per touch.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The C ABI only carried a change's kind and path, so a binding could not
show what a run replaced. One accessor fills a pointer-free struct with
either side (kind, mode, size, digest) so every binding can hold it on the
stack, and a second returns a symlink target as a string. The existing
kind and path accessors keep their signatures.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Change now carries the workdir entry the run first saw and the branch
entry it left, each as an Entry with kind, mode, size, digest, and link
target. kind is derived from which sides exist, so the README examples keep
printing "A out.txt", and renames() pairs moved files by digest.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Change now carries the Before and After entries (kind, mode, size, digest,
link target) and derives its kind from which sides exist; Renames pairs
moved files by digest. The ChangeKind constants are unchanged.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Signed-off-by: Cong Wang <cwang@multikernel.io>
lstat_entry_in_root mapped char and block devices to "absent", the same
answer as ENOENT. A device node in the upper was then reported as a
deletion of a path that still exists, and one the run removed from the
workdir lost its before side. Other exists for entries that carry no
bytes, so devices belong there alongside fifos and sockets.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The entry mode is st_mode & 0o7777, but os.FileMode keeps setuid, setgid,
and sticky in its own high bits. Casting the raw value put those three
bits where FileMode never looks, so Perm() silently dropped a setuid file
to 0755.

Signed-off-by: Cong Wang <cwang@multikernel.io>
Every other discriminant the header exposes is a named enum, but the
entry kind and the side selector were bare integers documented only in a
comment, so each binding mirrored them by position. sandlock_entry_kind
and SANDLOCK_CHANGE_BEFORE/AFTER give C callers and cbindgen consumers
the names, and widen kind to the uint32 the sibling enums use.

Signed-off-by: Cong Wang <cwang@multikernel.io>
The kind of a change is now derived from which sides are present, and
both bindings read the sides directly, so the character accessor had no
callers left. C callers get the same answer from
sandlock_result_change_entry's return value on each side.

Signed-off-by: Cong Wang <cwang@multikernel.io>
@congwang-mk
congwang-mk merged commit 4c54f73 into main Sep 11, 2026
17 checks passed
@congwang-mk
congwang-mk deleted the change-set-origins branch September 11, 2026 21:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant