use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::process::{ProcId, ProcessTable};
pub const LEDGER_FILE: &str = "opencode-desktop-service.json";
const LEDGER_MAX_BYTES: u64 = 16 * 1024;
#[must_use]
pub fn ledger_path(openlatch_dir: &Path) -> PathBuf {
openlatch_dir.join("state").join(LEDGER_FILE)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Ledger {
pub pid: u32,
pub start: String,
pub binary: PathBuf,
pub sha256: String,
pub version: String,
pub started_at: u64,
pub proxy_url: String,
pub ca_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRegistration {
pub(super) pid: u32,
pub(super) version: Option<String>,
}
impl VerifiedRegistration {
#[must_use]
pub fn pid(&self) -> u32 {
self.pid
}
#[must_use]
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
}
}
impl Ledger {
#[must_use]
pub fn names(&self, current: &ProcId) -> bool {
current.pid == self.pid
&& current.start == self.start
&& super::process::same_path(¤t.exe, &self.binary)
}
#[must_use]
pub fn owns(&self, registered: &VerifiedRegistration, fresh: Option<&ProcId>) -> bool {
registered.pid == self.pid && fresh.is_some_and(|p| self.names(p))
}
#[must_use]
pub fn armed(
&self,
env_json: Option<&crate::hooks::proxy_env_file::EnvJson>,
ca_sha256: Option<&str>,
) -> bool {
env_json.is_some_and(|e| e.https_proxy == self.proxy_url)
&& ca_sha256.is_some_and(|ca| ca.eq_ignore_ascii_case(&self.ca_sha256))
}
}
#[must_use]
pub fn read(openlatch_dir: &Path) -> Option<Ledger> {
use std::io::Read;
let file = std::fs::File::open(ledger_path(openlatch_dir)).ok()?;
let mut raw = Vec::new();
file.take(LEDGER_MAX_BYTES + 1).read_to_end(&mut raw).ok()?;
if raw.len() as u64 > LEDGER_MAX_BYTES {
return None;
}
serde_json::from_slice(&raw).ok()
}
pub fn write(openlatch_dir: &Path, ledger: &Ledger) -> std::io::Result<()> {
let path = ledger_path(openlatch_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_string_pretty(ledger).map_err(std::io::Error::other)?;
crate::core::fs_secure::write_owner_only(&path, &body)
}
pub fn remove(openlatch_dir: &Path) -> std::io::Result<()> {
match std::fs::remove_file(ledger_path(openlatch_dir)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Current {
None,
Own(Ledger, ProcId),
Unverified(Ledger),
}
pub fn current(openlatch_dir: &Path, table: &dyn ProcessTable) -> Current {
let Some(ledger) = read(openlatch_dir) else {
return Current::None;
};
let reason = match table.read(ledger.pid) {
Some(p) if ledger.names(&p) => return Current::Own(ledger, p),
Some(_) => "stale ledger: pid reused by another process",
None if table.exists(ledger.pid) => return Current::Unverified(ledger),
None => "stale ledger: process gone",
};
tracing::info!(
pid = ledger.pid,
binary = %ledger.binary.display(),
reason,
"removing the OpenCode service ledger"
);
if let Err(e) = remove(openlatch_dir) {
tracing::warn!(error = %e, "could not remove the OpenCode service ledger");
}
Current::None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::proxy_env_file::EnvJson;
fn sample() -> Ledger {
Ledger {
pid: 4242,
start: "987654".into(),
binary: PathBuf::from("opencode-cli"),
sha256: "ab".repeat(32),
version: "2.0.26".into(),
started_at: 1_760_000_000_000,
proxy_url: "http://127.0.0.1:47811".into(),
ca_sha256: "cd".repeat(32),
}
}
fn env(proxy: &str) -> EnvJson {
EnvJson {
https_proxy: proxy.into(),
http_proxy: proxy.into(),
no_proxy: "localhost".into(),
node_extra_ca_certs: "/ol/ca.pem".into(),
instance_id: "0".repeat(32),
}
}
#[test]
fn round_trips_and_removes() {
let dir = tempfile::tempdir().unwrap();
assert!(read(dir.path()).is_none());
write(dir.path(), &sample()).unwrap();
assert_eq!(read(dir.path()), Some(sample()));
remove(dir.path()).unwrap();
assert!(read(dir.path()).is_none());
remove(dir.path()).unwrap();
}
#[test]
fn an_oversized_or_foreign_shaped_ledger_is_not_read() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("state")).unwrap();
std::fs::write(ledger_path(dir.path()), "x".repeat(20 * 1024)).unwrap();
assert!(read(dir.path()).is_none());
std::fs::write(ledger_path(dir.path()), r#"{"pid":1,"extra":true}"#).unwrap();
assert!(read(dir.path()).is_none());
}
#[cfg(unix)]
#[test]
fn the_ledger_is_owner_only_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
write(dir.path(), &sample()).unwrap();
let mode = std::fs::metadata(ledger_path(dir.path()))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "expected 0600, got {:o}", mode & 0o777);
}
#[cfg(windows)]
#[test]
fn the_ledger_is_owner_only_on_windows() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), &sample()).unwrap();
let out = std::process::Command::new("icacls")
.arg(ledger_path(dir.path()))
.output()
.expect("icacls runs");
let acl = String::from_utf8_lossy(&out.stdout);
assert!(
acl.contains("OWNER RIGHTS") || acl.contains("S-1-3-4"),
"owner ACE missing: {acl}"
);
assert!(!acl.contains("(I)"), "inherited ACEs survived: {acl}");
assert!(
!acl.contains("\\Users:") && !acl.contains("BUILTIN\\Users"),
"Users group still has access: {acl}"
);
}
#[test]
fn owns_needs_the_verified_pid_and_a_matching_fresh_read() {
let l = sample();
let fresh = ProcId {
pid: 4242,
start: "987654".into(),
exe: PathBuf::from("opencode-cli"),
};
let verified = VerifiedRegistration {
pid: 4242,
version: Some("2.0.26".into()),
};
assert!(l.owns(&verified, Some(&fresh)));
assert!(!l.owns(&verified, None));
let reused = ProcId {
start: "111".into(),
..fresh.clone()
};
assert!(!l.owns(&verified, Some(&reused)));
let other = ProcId {
exe: PathBuf::from("opencode"),
..fresh.clone()
};
assert!(!l.owns(&verified, Some(&other)));
let elsewhere = VerifiedRegistration {
pid: 7,
version: None,
};
assert!(!l.owns(&elsewhere, Some(&fresh)));
}
#[test]
fn armed_needs_both_the_proxy_and_the_ca_digest() {
let l = sample();
let ca = "cd".repeat(32);
assert!(l.armed(Some(&env("http://127.0.0.1:47811")), Some(&ca)));
assert!(!l.armed(None, Some(&ca)), "env.json withdrawn");
assert!(
!l.armed(Some(&env("http://127.0.0.1:1")), Some(&ca)),
"proxy moved"
);
assert!(
!l.armed(Some(&env("http://127.0.0.1:47811")), Some(&"ef".repeat(32))),
"CA rotated in place"
);
assert!(
!l.armed(Some(&env("http://127.0.0.1:47811")), None),
"no CA"
);
}
}