diff --git a/agents/hermes/seed-dashboard-config.py b/agents/hermes/seed-dashboard-config.py index 841733460cb..792eece4f40 100755 --- a/agents/hermes/seed-dashboard-config.py +++ b/agents/hermes/seed-dashboard-config.py @@ -98,19 +98,26 @@ def _rename_no_replace_at(src_fd: int, name: str, dst_fd: int) -> None: import ctypes libc = ctypes.CDLL(None, use_errno=True) - renameat2 = getattr(libc, "renameat2", None) - if renameat2 is None: - raise OSError(errno.ENOSYS, "renameat2 is unavailable") - renameat2.argtypes = [ + if sys.platform == "darwin": + rename_no_replace = getattr(libc, "renameatx_np", None) + rename_flag = 0x00000004 # RENAME_EXCL from Darwin sys/stdio.h. + unavailable_message = "renameatx_np is unavailable" + else: + rename_no_replace = getattr(libc, "renameat2", None) + rename_flag = 1 # RENAME_NOREPLACE from Linux stdio.h. + unavailable_message = "renameat2 is unavailable" + if rename_no_replace is None: + raise OSError(errno.ENOSYS, unavailable_message) + rename_no_replace.argtypes = [ ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint, ] - renameat2.restype = ctypes.c_int + rename_no_replace.restype = ctypes.c_int encoded = os.fsencode(name) - if renameat2(src_fd, encoded, dst_fd, encoded, 1) != 0: + if rename_no_replace(src_fd, encoded, dst_fd, encoded, rename_flag) != 0: error_number = ctypes.get_errno() raise OSError(error_number, os.strerror(error_number)) diff --git a/test/hermes-dashboard-profile-migration-security.test.ts b/test/hermes-dashboard-profile-migration-security.test.ts index 57b0fcda804..1da2af4b593 100644 --- a/test/hermes-dashboard-profile-migration-security.test.ts +++ b/test/hermes-dashboard-profile-migration-security.test.ts @@ -52,6 +52,56 @@ describe("Hermes dashboard profile migration security", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + it("moves between anchored directories without replacing a peer", () => { + const sourceDir = path.join(tmpDir, "source"); + const destinationDir = path.join(tmpDir, "destination"); + fs.mkdirSync(sourceDir); + fs.mkdirSync(destinationDir); + fs.writeFileSync(path.join(sourceDir, "state"), "legacy\n"); + + const harness = ` +import errno +import importlib.util +import os +import sys + +spec = importlib.util.spec_from_file_location("seed_dashboard_config", sys.argv[1]) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +source_fd = module._open_directory_no_follow(sys.argv[2]) +destination_fd = module._open_directory_no_follow(sys.argv[3]) +try: + module._rename_no_replace_at(source_fd, "state", destination_fd) + source_state_fd = os.open( + "state", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=source_fd + ) + try: + os.write(source_state_fd, b"retry\\n") + finally: + os.close(source_state_fd) + try: + module._rename_no_replace_at(source_fd, "state", destination_fd) + except OSError as exc: + if exc.errno != errno.EEXIST: + raise + else: + raise AssertionError("no-clobber rename replaced the destination") +finally: + os.close(destination_fd) + os.close(source_fd) +`; + const res = spawnSync("python3", ["-c", harness, SCRIPT_PATH, sourceDir, destinationDir], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 10_000, + }); + + expect(res.status, res.stderr).toBe(0); + expect(fs.readFileSync(path.join(sourceDir, "state"), "utf-8")).toBe("retry\n"); + expect(fs.readFileSync(path.join(destinationDir, "state"), "utf-8")).toBe("legacy\n"); + }); + it("does not replace a destination recreated before the whole-profile move (#7200)", () => { const hermesHome = path.join(tmpDir, ".hermes"); const legacyHome = path.join(hermesHome, "dashboard-home");