Skip to content

Commit a392cd3

Browse files
rgbkrkclaude
andauthored
fix(runtimed): use atomic binary replacement to prevent code signature crash (#1056)
During daemon upgrades, `std::fs::copy` overwrites the binary in-place (same inode, O_TRUNC). If launchd's KeepAlive restarts the daemon between the stop and copy steps, the restarted process has the old binary mapped. The in-place write invalidates its code-signature pages, and the *new* daemon — which inherits the same inode — can crash minutes later when macOS demand-pages an unloaded __TEXT page whose hash no longer matches the code directory (EXC_BAD_ACCESS / SIGKILL Code Signature Invalid). Replace the in-place copy with write-to-temp + atomic rename, which swaps the directory entry to a new inode. Any process still mapped to the old inode keeps valid pages, and the new daemon maps a pristine inode with a clean signature. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1b0a911 commit a392cd3

File tree

1 file changed

+52
-83
lines changed

1 file changed

+52
-83
lines changed

crates/runtimed/src/service.rs

Lines changed: 52 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -121,58 +121,6 @@ impl ServiceManager {
121121
Self { config }
122122
}
123123

124-
/// Remove the com.apple.quarantine extended attribute from the binary.
125-
///
126-
/// macOS adds this attribute to files downloaded from the internet, and Gatekeeper
127-
/// may block execution of quarantined binaries. We proactively remove it after
128-
/// copying the binary to prevent startup failures.
129-
///
130-
/// Returns Ok(true) if quarantine was removed, Ok(false) if not quarantined,
131-
/// or Err with a warning message if removal failed.
132-
#[cfg(target_os = "macos")]
133-
fn remove_quarantine(&self) -> Result<bool, String> {
134-
use std::process::Command;
135-
136-
// First check if the binary is quarantined
137-
let check = Command::new("xattr")
138-
.args(["-p", "com.apple.quarantine"])
139-
.arg(&self.config.binary_path)
140-
.output();
141-
142-
match check {
143-
Ok(o) if o.status.success() => {
144-
// Binary is quarantined, try to remove it
145-
let remove = Command::new("xattr")
146-
.args(["-d", "com.apple.quarantine"])
147-
.arg(&self.config.binary_path)
148-
.output();
149-
150-
match remove {
151-
Ok(o) if o.status.success() => {
152-
info!("[service] Removed quarantine attribute from binary");
153-
Ok(true)
154-
}
155-
Ok(o) => {
156-
let stderr = String::from_utf8_lossy(&o.stderr);
157-
Err(format!(
158-
"Failed to remove quarantine (binary may be blocked by Gatekeeper): {}",
159-
stderr.trim()
160-
))
161-
}
162-
Err(e) => Err(format!("xattr command failed: {}", e)),
163-
}
164-
}
165-
Ok(_) => {
166-
// Not quarantined (xattr -p returns non-zero when attribute doesn't exist)
167-
Ok(false)
168-
}
169-
Err(e) => {
170-
// xattr command itself failed - unusual but not fatal
171-
Err(format!("Could not check quarantine status: {}", e))
172-
}
173-
}
174-
}
175-
176124
/// Install the daemon as a system service.
177125
///
178126
/// This copies the binary to a persistent location and creates the
@@ -187,31 +135,66 @@ impl ServiceManager {
187135
std::fs::create_dir_all(parent)?;
188136
}
189137

190-
// Copy binary to persistent location
191-
std::fs::copy(source_binary, &self.config.binary_path)?;
192-
info!(
193-
"[service] Installed binary to {:?}",
194-
self.config.binary_path
195-
);
138+
// Atomically replace the binary (write to temp + rename) to avoid
139+
// corrupting a running daemon's mapped pages on macOS. See the
140+
// doc comment on `atomic_copy_binary` for details.
141+
self.atomic_copy_binary(source_binary)?;
142+
143+
// Create service configuration
144+
self.create_service_config()?;
145+
146+
info!("[service] Service installed successfully");
147+
Ok(())
148+
}
149+
150+
/// Copy a binary to `self.config.binary_path` via a temporary file and
151+
/// atomic `rename`, then set permissions and remove quarantine.
152+
///
153+
/// A plain `std::fs::copy` truncates and rewrites the *same inode*.
154+
/// On macOS, if a `KeepAlive`-restarted daemon still has the old inode
155+
/// memory-mapped, the in-place write invalidates its code-signature
156+
/// pages. Worse, the *new* daemon inherits the same inode and can
157+
/// crash minutes later when macOS demand-pages an unloaded `__TEXT`
158+
/// page whose hash no longer matches the code directory.
159+
///
160+
/// Writing to a temp file and then `rename`-ing atomically swaps the
161+
/// directory entry to a **new inode**, so:
162+
/// - any process still mapped to the old inode keeps valid pages,
163+
/// - the new daemon maps a pristine inode with a clean signature.
164+
fn atomic_copy_binary(&self, source_binary: &PathBuf) -> ServiceResult<()> {
165+
let tmp_path = self.config.binary_path.with_extension("new");
166+
167+
// Copy to a temp file (creates a new inode)
168+
std::fs::copy(source_binary, &tmp_path)?;
196169

197-
// Make binary executable on Unix
170+
// Set permissions on the temp file before rename
198171
#[cfg(unix)]
199172
{
200173
use std::os::unix::fs::PermissionsExt;
201174
let perms = std::fs::Permissions::from_mode(0o755);
202-
std::fs::set_permissions(&self.config.binary_path, perms)?;
175+
std::fs::set_permissions(&tmp_path, perms)?;
203176
}
204177

205-
// Remove quarantine attribute on macOS (Gatekeeper may block quarantined binaries)
178+
// Remove quarantine on the temp file before rename
206179
#[cfg(target_os = "macos")]
207-
if let Err(warning) = self.remove_quarantine() {
208-
log::warn!("[service] {}", warning);
180+
{
181+
use std::process::Command;
182+
// Best-effort: if quarantine removal fails, the rename still
183+
// proceeds — Gatekeeper may prompt but won't crash.
184+
let _ = Command::new("xattr")
185+
.args(["-d", "com.apple.quarantine"])
186+
.arg(&tmp_path)
187+
.output();
209188
}
210189

211-
// Create service configuration
212-
self.create_service_config()?;
190+
// Atomic swap — old inode stays valid for any mapped process
191+
std::fs::rename(&tmp_path, &self.config.binary_path)?;
192+
193+
info!(
194+
"[service] Installed binary to {:?}",
195+
self.config.binary_path
196+
);
213197

214-
info!("[service] Service installed successfully");
215198
Ok(())
216199
}
217200

@@ -252,23 +235,9 @@ impl ServiceManager {
252235
// Stop the running daemon (ignore errors - may not be running)
253236
self.stop().ok();
254237

255-
// Replace the binary
256-
std::fs::copy(source_binary, &self.config.binary_path)?;
257-
info!("[service] Replaced binary at {:?}", self.config.binary_path);
258-
259-
// Make binary executable on Unix
260-
#[cfg(unix)]
261-
{
262-
use std::os::unix::fs::PermissionsExt;
263-
let perms = std::fs::Permissions::from_mode(0o755);
264-
std::fs::set_permissions(&self.config.binary_path, perms)?;
265-
}
266-
267-
// Remove quarantine attribute on macOS (Gatekeeper may block quarantined binaries)
268-
#[cfg(target_os = "macos")]
269-
if let Err(warning) = self.remove_quarantine() {
270-
log::warn!("[service] {}", warning);
271-
}
238+
// Atomically replace the binary (write to temp + rename) so that
239+
// any daemon still mapped to the old inode keeps valid pages.
240+
self.atomic_copy_binary(source_binary)?;
272241

273242
// Recreate service config to apply any template changes (e.g., new env vars)
274243
self.create_service_config()?;

0 commit comments

Comments
 (0)