Skip to content

Commit 80caea6

Browse files
committed
Add COW interception for chown/fchownat and utimensat/futimesat
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent cc441ef commit 80caea6

File tree

4 files changed

+176
-5
lines changed

4 files changed

+176
-5
lines changed

src/sandlock/_context.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,12 +115,13 @@ def _notif_syscall_names(notif: "NotifPolicy") -> list[str]:
115115
cow_syscalls = [
116116
"unlinkat", "mkdirat", "renameat2",
117117
"newfstatat", "statx", "faccessat",
118-
"symlinkat", "linkat", "fchmodat",
119-
"readlinkat", "truncate", "getdents64",
118+
"symlinkat", "linkat", "fchmodat", "fchownat",
119+
"readlinkat", "truncate", "utimensat", "getdents64",
120120
# Non-at variants (x86_64 has both, aarch64 only has *at)
121121
"unlink", "rmdir", "mkdir", "rename",
122122
"stat", "lstat", "access",
123-
"symlink", "link", "chmod", "readlink",
123+
"symlink", "link", "chmod", "chown", "lchown",
124+
"readlink", "futimesat",
124125
]
125126
for name in cow_syscalls:
126127
if name in _SYSCALL_NR:

src/sandlock/_notif.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -660,18 +660,26 @@ def _dispatch(self, notif: SeccompNotif) -> None:
660660
nr_link = _SYSCALL_NR.get("link")
661661
nr_fchmodat = _SYSCALL_NR.get("fchmodat")
662662
nr_chmod = _SYSCALL_NR.get("chmod")
663+
nr_fchownat = _SYSCALL_NR.get("fchownat")
664+
nr_chown = _SYSCALL_NR.get("chown")
665+
nr_lchown = _SYSCALL_NR.get("lchown")
663666
nr_readlinkat = _SYSCALL_NR.get("readlinkat")
664667
nr_readlink = _SYSCALL_NR.get("readlink")
665668
nr_truncate = _SYSCALL_NR.get("truncate")
669+
nr_utimensat = _SYSCALL_NR.get("utimensat")
670+
nr_futimesat = _SYSCALL_NR.get("futimesat")
666671

667672
# *at variants: dirfd=arg0, pathname=arg1
668673
cow_at_nrs = {nr_unlinkat, nr_mkdirat, nr_renameat2,
669674
nr_newfstatat, nr_statx, nr_faccessat,
670-
nr_fchmodat, nr_readlinkat} - {None}
675+
nr_fchmodat, nr_fchownat, nr_readlinkat,
676+
nr_utimensat} - {None}
671677
# non-at variants: pathname=arg0
672678
cow_plain_nrs = {nr_unlink, nr_rmdir, nr_mkdir, nr_rename,
673679
nr_stat, nr_lstat, nr_access,
674-
nr_chmod, nr_readlink, nr_truncate} - {None}
680+
nr_chmod, nr_chown, nr_lchown,
681+
nr_readlink, nr_truncate,
682+
nr_futimesat} - {None}
675683
# Special arg layouts
676684
cow_special_nrs = {nr_symlinkat, nr_symlink,
677685
nr_linkat, nr_link} - {None}
@@ -838,6 +846,74 @@ def _dispatch(self, notif: SeccompNotif) -> None:
838846
self._respond_continue(notif.id)
839847
return
840848

849+
# chown / fchownat / lchown
850+
if nr in (nr_fchownat, nr_chown, nr_lchown):
851+
if nr == nr_fchownat:
852+
uid = ctypes.c_int32(notif.data.args[2] & 0xFFFFFFFF).value
853+
gid = ctypes.c_int32(notif.data.args[3] & 0xFFFFFFFF).value
854+
follow = not bool(notif.data.args[4] & 0x100) # AT_SYMLINK_NOFOLLOW
855+
elif nr == nr_chown:
856+
uid = ctypes.c_int32(notif.data.args[1] & 0xFFFFFFFF).value
857+
gid = ctypes.c_int32(notif.data.args[2] & 0xFFFFFFFF).value
858+
follow = True
859+
else: # lchown
860+
uid = ctypes.c_int32(notif.data.args[1] & 0xFFFFFFFF).value
861+
gid = ctypes.c_int32(notif.data.args[2] & 0xFFFFFFFF).value
862+
follow = False
863+
if self._cow_handler.handle_chown(path, uid, gid,
864+
follow_symlinks=follow):
865+
self._respond_val(notif.id, 0)
866+
else:
867+
self._respond_continue(notif.id)
868+
return
869+
870+
# utimensat / futimesat
871+
if nr in (nr_utimensat, nr_futimesat):
872+
UTIME_NOW = (1 << 30) - 1
873+
UTIME_OMIT = (1 << 30) - 2
874+
times = None # default: current time
875+
if nr == nr_utimensat:
876+
# utimensat(dirfd, path, times[2], flags)
877+
times_addr = notif.data.args[2]
878+
follow = not bool(notif.data.args[3] & 0x100)
879+
if times_addr != 0:
880+
from ._procfs import read_bytes as _read
881+
raw = _read(pid, times_addr, 32)
882+
a_s, a_ns = struct.unpack_from("<qQ", raw, 0)
883+
m_s, m_ns = struct.unpack_from("<qQ", raw, 16)
884+
atime = None if a_ns == UTIME_OMIT else (
885+
None if a_ns == UTIME_NOW else a_s + a_ns / 1e9)
886+
mtime = None if m_ns == UTIME_OMIT else (
887+
None if m_ns == UTIME_NOW else m_s + m_ns / 1e9)
888+
if atime is not None or mtime is not None:
889+
# Need current stat for OMIT fields
890+
real = self._cow_handler.handle_stat(path)
891+
if real:
892+
st = os.stat(real)
893+
if atime is None:
894+
atime = st.st_atime
895+
if mtime is None:
896+
mtime = st.st_mtime
897+
times = (atime or 0, mtime or 0)
898+
# both UTIME_NOW → times=None (current time)
899+
else:
900+
# futimesat(dirfd, path, times[2])
901+
times_addr = notif.data.args[2]
902+
follow = True
903+
if times_addr != 0:
904+
from ._procfs import read_bytes as _read
905+
raw = _read(pid, times_addr, 32)
906+
a_s, a_us = struct.unpack_from("<qQ", raw, 0)
907+
m_s, m_us = struct.unpack_from("<qQ", raw, 16)
908+
times = (a_s + a_us / 1e6,
909+
m_s + m_us / 1e6)
910+
if self._cow_handler.handle_utimens(path, times,
911+
follow_symlinks=follow):
912+
self._respond_val(notif.id, 0)
913+
else:
914+
self._respond_continue(notif.id)
915+
return
916+
841917
# truncate (path-based)
842918
if nr == nr_truncate:
843919
length = notif.data.args[1]

src/sandlock/cowfs/_handler.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,25 @@ def handle_truncate(self, path: str, length: int) -> bool:
209209
upper_file = self._branch.ensure_cow_copy(rel_path)
210210
os.truncate(str(upper_file), length)
211211
return True
212+
213+
def handle_chown(self, path: str, uid: int, gid: int,
214+
follow_symlinks: bool = True) -> bool:
215+
"""Handle chown/fchownat: chown in upper (COW copy if needed)."""
216+
rel_path = os.path.relpath(path, self._workdir_str)
217+
upper_file = self._branch.ensure_cow_copy(rel_path)
218+
os.chown(str(upper_file), uid, gid,
219+
follow_symlinks=follow_symlinks)
220+
return True
221+
222+
def handle_utimens(self, path: str,
223+
times: tuple[float, float] | None,
224+
follow_symlinks: bool = True) -> bool:
225+
"""Handle utimensat: set timestamps in upper (COW copy if needed).
226+
227+
times is (atime, mtime) as floats, or None for current time.
228+
"""
229+
rel_path = os.path.relpath(path, self._workdir_str)
230+
upper_file = self._branch.ensure_cow_copy(rel_path)
231+
os.utime(str(upper_file), times=times,
232+
follow_symlinks=follow_symlinks)
233+
return True

tests/test_integration.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,78 @@ def test_disk_quota_only_counts_delta(self, isolation):
10651065
assert result.success
10661066
assert b"orig=51200" in result.stdout
10671067

1068+
def test_cow_chown_goes_to_upper(self, isolation):
1069+
"""chown on a COW file operates on the upper copy, not the original."""
1070+
with tempfile.TemporaryDirectory() as td:
1071+
os.makedirs(f"{td}/project")
1072+
with open(f"{td}/project/file.txt", "w") as f:
1073+
f.write("data")
1074+
orig_stat = os.stat(f"{td}/project/file.txt")
1075+
1076+
policy = _make_cow_policy(
1077+
f"{td}/project", td, isolation,
1078+
on_exit=BranchAction.ABORT,
1079+
)
1080+
result = Sandbox(policy).run(
1081+
["python3", "-c", """
1082+
import os
1083+
# chown to same uid/gid (always allowed, but triggers COW copy)
1084+
st = os.stat('file.txt')
1085+
os.chown('file.txt', st.st_uid, st.st_gid)
1086+
# Verify the file is still readable after chown
1087+
print('OK', open('file.txt').read())
1088+
"""]
1089+
)
1090+
assert result.success
1091+
assert b"OK data" in result.stdout
1092+
# Original unchanged after abort
1093+
assert open(f"{td}/project/file.txt").read() == "data"
1094+
1095+
def test_cow_utimensat_goes_to_upper(self, isolation):
1096+
"""utime on a COW file operates on the upper copy, not the original."""
1097+
with tempfile.TemporaryDirectory() as td:
1098+
os.makedirs(f"{td}/project")
1099+
with open(f"{td}/project/file.txt", "w") as f:
1100+
f.write("data")
1101+
orig_mtime = os.stat(f"{td}/project/file.txt").st_mtime
1102+
1103+
policy = _make_cow_policy(
1104+
f"{td}/project", td, isolation,
1105+
on_exit=BranchAction.ABORT,
1106+
)
1107+
result = Sandbox(policy).run(
1108+
["python3", "-c", """
1109+
import os
1110+
os.utime('file.txt', (1000000, 1000000))
1111+
st = os.stat('file.txt')
1112+
print(f'MTIME {int(st.st_mtime)}')
1113+
"""]
1114+
)
1115+
assert result.success
1116+
assert b"MTIME 1000000" in result.stdout
1117+
# Original mtime unchanged after abort
1118+
assert os.stat(f"{td}/project/file.txt").st_mtime == orig_mtime
1119+
1120+
def test_cow_utimensat_committed(self, isolation):
1121+
"""utime changes are committed on success."""
1122+
with tempfile.TemporaryDirectory() as td:
1123+
os.makedirs(f"{td}/project")
1124+
with open(f"{td}/project/file.txt", "w") as f:
1125+
f.write("data")
1126+
1127+
policy = _make_cow_policy(f"{td}/project", td, isolation)
1128+
result = Sandbox(policy).run(
1129+
["python3", "-c", """
1130+
import os
1131+
os.utime('file.txt', (2000000, 2000000))
1132+
print('OK')
1133+
"""]
1134+
)
1135+
assert result.success
1136+
assert b"OK" in result.stdout
1137+
# After commit, mtime should be updated
1138+
assert int(os.stat(f"{td}/project/file.txt").st_mtime) == 2000000
1139+
10681140

10691141
# --- Deterministic randomness ---
10701142

0 commit comments

Comments
 (0)