fix(harness): verify socket permissions and report partial cleanup
This commit is contained in:
@@ -132,9 +132,12 @@ def _write_handle(path: Path, state: dict, *, exclusive: bool = False, on_create
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _update_handle(path: Path, state: dict) -> None:
|
||||
def _update_handle(path: Path, state: dict, *, expected_identity: tuple[int, int] | None = None) -> None:
|
||||
fd = _open_handle(path, os.O_WRONLY)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if expected_identity is not None and (st.st_dev, st.st_ino) != expected_identity:
|
||||
raise ValueError("handle identity changed")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
os.ftruncate(fd, 0)
|
||||
_write_all(fd, (json.dumps(state, sort_keys=True, separators=(",", ":")) + "\n").encode())
|
||||
@@ -202,6 +205,7 @@ class Broker:
|
||||
self.handle_created = False
|
||||
self.handle_identity: tuple[int, int] | None = None
|
||||
self.started = False
|
||||
self.cleanup_result: dict | None = None
|
||||
|
||||
def _record_handle_identity(self, identity: tuple[int, int]) -> None:
|
||||
self.handle_identity = identity
|
||||
@@ -213,54 +217,74 @@ class Broker:
|
||||
persisted = dict(self.state)
|
||||
if not retain_token:
|
||||
persisted.pop("token", None)
|
||||
_update_handle(self.handle, persisted)
|
||||
_update_handle(self.handle, persisted, expected_identity=self.handle_identity)
|
||||
|
||||
def cleanup(self, status: str) -> bool:
|
||||
released = False
|
||||
try:
|
||||
if self.set.repository.held:
|
||||
self.set.repository.release()
|
||||
self.set.release_issue()
|
||||
released = True
|
||||
except Exception:
|
||||
released = False
|
||||
if released and self.handle_created:
|
||||
# Keep a previous partial result: a second cleanup must not erase failures.
|
||||
previous = self.cleanup_result or {}
|
||||
errors = list(previous.get("errors", []))
|
||||
for name, release in (("repository-release", self.set.repository.release),
|
||||
("issue-release", self.set.release_issue)):
|
||||
try:
|
||||
self.persist(status, retain_token=False)
|
||||
if name == "issue-release" or self.set.repository.held:
|
||||
release()
|
||||
except Exception:
|
||||
released = False
|
||||
errors.append(name)
|
||||
released = not self.set.issue.held and not self.set.repository.held
|
||||
if self.server:
|
||||
self.server.close()
|
||||
self.server = None
|
||||
if self.socket_identity is not None:
|
||||
server, self.server = self.server, None
|
||||
try:
|
||||
if self.socket_parent_fd is not None:
|
||||
st = os.stat(self.sock_path.name, dir_fd=self.socket_parent_fd, follow_symlinks=False)
|
||||
if (st.st_dev, st.st_ino) == self.socket_identity:
|
||||
os.unlink(self.sock_path.name, dir_fd=self.socket_parent_fd)
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
if self.handle_created and not self.started:
|
||||
try:
|
||||
parent_fd, name = _open_parent(self.handle)
|
||||
try:
|
||||
st = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
||||
if self.handle_identity == (st.st_dev, st.st_ino):
|
||||
try:
|
||||
self.persist(status, retain_token=False)
|
||||
except Exception:
|
||||
os.unlink(name, dir_fd=parent_fd)
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
server.close()
|
||||
except OSError:
|
||||
pass
|
||||
errors.append("server-close")
|
||||
socket_removed = previous.get("socket_removed", self.socket_identity is None)
|
||||
if self.socket_identity is not None and self.socket_parent_fd is not None:
|
||||
try:
|
||||
st = os.stat(self.sock_path.name, dir_fd=self.socket_parent_fd, follow_symlinks=False)
|
||||
if (not stat.S_ISSOCK(st.st_mode) or st.st_uid != os.getuid()
|
||||
or (st.st_dev, st.st_ino) != self.socket_identity):
|
||||
errors.append("socket-identity-changed")
|
||||
else:
|
||||
os.unlink(self.sock_path.name, dir_fd=self.socket_parent_fd)
|
||||
socket_removed = True
|
||||
self.socket_identity = None
|
||||
except FileNotFoundError:
|
||||
socket_removed = True
|
||||
self.socket_identity = None
|
||||
except OSError:
|
||||
errors.append("socket-unlink")
|
||||
if self.socket_parent_fd is not None:
|
||||
fd, self.socket_parent_fd = self.socket_parent_fd, None
|
||||
try:
|
||||
os.close(self.socket_parent_fd)
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
self.socket_parent_fd = None
|
||||
return released
|
||||
errors.append("socket-parent-close")
|
||||
self.cleanup_result = {"lease_released": released, "socket_removed": bool(socket_removed),
|
||||
"handle_saved": False, "errors": sorted(set(errors))}
|
||||
if self.handle_created:
|
||||
try:
|
||||
# Do not claim another object's handle during failed startup.
|
||||
if not self.started and self.handle_identity is not None:
|
||||
fd = _open_handle(self.handle, os.O_RDONLY)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if (st.st_dev, st.st_ino) != self.handle_identity:
|
||||
raise ValueError("handle identity changed")
|
||||
finally:
|
||||
os.close(fd)
|
||||
self.cleanup_result["handle_saved"] = True
|
||||
self.state["cleanup"] = self.cleanup_result
|
||||
terminal_status = status if released and socket_removed and not errors else "cleanup-incomplete"
|
||||
self.persist(terminal_status, retain_token=not released)
|
||||
except Exception:
|
||||
self.cleanup_result["handle_saved"] = False
|
||||
self.cleanup_result["errors"] = sorted(set(self.cleanup_result["errors"] + ["handle-save"]))
|
||||
else:
|
||||
self.cleanup_result["handle_saved"] = True # No owned handle to save.
|
||||
if self.cleanup_result["errors"]:
|
||||
print(json.dumps({"event": "cleanup-incomplete", "cleanup": self.cleanup_result},
|
||||
separators=(",", ":")), file=sys.stderr, flush=True)
|
||||
return released and bool(socket_removed) and not self.cleanup_result["errors"]
|
||||
|
||||
def authenticate(self, req: dict) -> bool:
|
||||
supplied = req.get("token")
|
||||
@@ -300,9 +324,10 @@ class Broker:
|
||||
self.persist("held")
|
||||
return {"status": "held"}
|
||||
if action == "release":
|
||||
if not self.cleanup("released"):
|
||||
return {"error": "release-failed"}
|
||||
complete = self.cleanup("released")
|
||||
self.stopping = True
|
||||
if not complete:
|
||||
return {"error": "cleanup-incomplete", "cleanup": self.cleanup_result}
|
||||
return {"status": "released"}
|
||||
return {"error": "invalid action"}
|
||||
|
||||
@@ -318,7 +343,8 @@ class Broker:
|
||||
except BaseException:
|
||||
raise
|
||||
st = self.handle.lstat()
|
||||
self.handle_identity = (st.st_dev, st.st_ino)
|
||||
if (st.st_dev, st.st_ino) != self.handle_identity:
|
||||
raise ValueError("created handle identity changed")
|
||||
self.handle_created = True
|
||||
self.socket_parent_fd, socket_name = _open_parent(self.sock_path)
|
||||
try:
|
||||
@@ -328,9 +354,17 @@ class Broker:
|
||||
pass
|
||||
self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
self.server.bind(f"/proc/self/fd/{self.socket_parent_fd}/{socket_name}")
|
||||
sock_st = os.fstat(self.server.fileno())
|
||||
# Socket FD metadata describes sockfs, not the bound filesystem node.
|
||||
sock_st = os.stat(socket_name, dir_fd=self.socket_parent_fd, follow_symlinks=False)
|
||||
if not stat.S_ISSOCK(sock_st.st_mode) or sock_st.st_uid != os.getuid():
|
||||
raise ValueError("unsafe bound socket")
|
||||
self.socket_identity = (sock_st.st_dev, sock_st.st_ino)
|
||||
os.fchmod(self.server.fileno(), 0o600)
|
||||
os.chmod(socket_name, 0o600, dir_fd=self.socket_parent_fd, follow_symlinks=False)
|
||||
verified = os.stat(socket_name, dir_fd=self.socket_parent_fd, follow_symlinks=False)
|
||||
if (not stat.S_ISSOCK(verified.st_mode) or verified.st_uid != os.getuid()
|
||||
or (verified.st_dev, verified.st_ino) != self.socket_identity
|
||||
or stat.S_IMODE(verified.st_mode) != 0o600):
|
||||
raise ValueError("bound socket verification failed")
|
||||
self.server.listen(8); self.server.settimeout(0.2)
|
||||
self.started = True
|
||||
print(json.dumps({"status": "held", "handle": str(self.handle), "socket": str(self.sock_path)}, separators=(",", ":")), flush=True)
|
||||
@@ -352,8 +386,11 @@ class Broker:
|
||||
response = {"error": "invalid request"}
|
||||
conn.sendall((json.dumps(response, separators=(",", ":")) + "\n").encode())
|
||||
finally:
|
||||
if self.set.issue.held or self.handle_created or self.server:
|
||||
if (self.set.issue.held or self.set.repository.held
|
||||
or (self.cleanup_result is None and (self.handle_created or self.server))):
|
||||
self.cleanup("released" if not self.stopping else "expired")
|
||||
if self.cleanup_result and self.cleanup_result["errors"]:
|
||||
raise OSError("broker cleanup incomplete")
|
||||
|
||||
|
||||
def serve(args: argparse.Namespace) -> int:
|
||||
|
||||
Reference in New Issue
Block a user