use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const MARKER_RELATIVE: &str = "state/pending-switch.json";
pub const MARKER_TTL: Duration = Duration::from_secs(60 * 60);
const DELETE_ATTEMPTS: u32 = 3;
const DELETE_RETRY_PAUSE: Duration = Duration::from_millis(50);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingSwitch {
pub organization_id: String,
pub key_sha256: String,
pub created_at: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarkerAction {
Apply,
Keep,
Expire,
}
impl PendingSwitch {
pub fn new(organization_id: &str, key: &SecretString, now: u64) -> Self {
Self {
organization_id: organization_id.to_string(),
key_sha256: key_sha256(key),
created_at: now,
}
}
pub fn rule(&self, loaded_key: Option<&SecretString>, now: u64) -> MarkerAction {
if loaded_key.is_some_and(|key| self.matches(key)) {
return MarkerAction::Apply;
}
if now.saturating_sub(self.created_at) >= MARKER_TTL.as_secs() {
MarkerAction::Expire
} else {
MarkerAction::Keep
}
}
pub fn matches(&self, key: &SecretString) -> bool {
key_sha256(key).eq_ignore_ascii_case(&self.key_sha256)
}
}
pub fn key_sha256(key: &SecretString) -> String {
let digest = Sha256::digest(key.expose_secret().as_bytes());
digest.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn marker_path(openlatch_dir: &Path) -> PathBuf {
openlatch_dir.join(MARKER_RELATIVE)
}
pub fn write(openlatch_dir: &Path, marker: &PendingSwitch) -> io::Result<()> {
let path = marker_path(openlatch_dir);
if let Some(parent) = path.parent() {
crate::core::fs_secure::create_dir_owner_only(parent)?;
}
let body = serde_json::to_string(marker).map_err(io::Error::other)?;
crate::core::fs_secure::write_owner_only(&path, &body)
}
pub fn read(openlatch_dir: &Path) -> io::Result<Option<PendingSwitch>> {
let path = marker_path(openlatch_dir);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
pub fn delete(openlatch_dir: &Path) -> io::Result<()> {
delete_path(&marker_path(openlatch_dir))
}
fn delete_path(path: &Path) -> io::Result<()> {
let mut attempt = 0;
loop {
attempt += 1;
match std::fs::remove_file(path) {
Ok(()) => return Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) if is_sharing_violation(&e) && attempt < DELETE_ATTEMPTS => {
std::thread::sleep(DELETE_RETRY_PAUSE);
}
Err(e) => return Err(e),
}
}
}
fn is_sharing_violation(e: &io::Error) -> bool {
cfg!(windows) && matches!(e.raw_os_error(), Some(32) | Some(33))
}
pub use crate::core::cloud::now_unix_secs;
#[cfg(test)]
mod tests {
use super::*;
fn key(s: &str) -> SecretString {
SecretString::from(s.to_string())
}
const NOW: u64 = 1_800_000_000;
#[test]
fn pending_switch_rule_applies_when_the_loaded_key_is_the_marker_key() {
let marker = PendingSwitch::new("org-b", &key("openlatch_new"), NOW);
assert_eq!(
marker.rule(Some(&key("openlatch_new")), NOW + 7200),
MarkerAction::Apply,
"a matching key applies however old the marker is"
);
}
#[test]
fn pending_switch_rule_keeps_a_young_marker_for_another_key() {
let marker = PendingSwitch::new("org-b", &key("openlatch_new"), NOW);
assert_eq!(
marker.rule(Some(&key("openlatch_old")), NOW),
MarkerAction::Keep
);
assert_eq!(
marker.rule(Some(&key("openlatch_old")), NOW + MARKER_TTL.as_secs() - 1),
MarkerAction::Keep
);
assert_eq!(marker.rule(None, NOW + 10), MarkerAction::Keep);
}
#[test]
fn pending_switch_rule_expires_a_marker_an_hour_old() {
let marker = PendingSwitch::new("org-b", &key("openlatch_new"), NOW);
assert_eq!(
marker.rule(Some(&key("openlatch_old")), NOW + MARKER_TTL.as_secs()),
MarkerAction::Expire
);
assert_eq!(marker.rule(None, NOW + 10_000), MarkerAction::Expire);
}
#[test]
fn pending_switch_rule_never_panics_on_a_clock_before_the_marker() {
let marker = PendingSwitch::new("org-b", &key("k"), NOW);
assert_eq!(marker.rule(None, NOW - 100), MarkerAction::Keep);
}
#[test]
fn pending_switch_key_sha256_is_lower_hex_of_the_key_bytes() {
assert_eq!(
key_sha256(&key("abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn pending_switch_write_read_delete_round_trip() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(read(tmp.path()).unwrap(), None);
let marker = PendingSwitch::new("org-b", &key("openlatch_new"), NOW);
write(tmp.path(), &marker).unwrap();
assert_eq!(read(tmp.path()).unwrap(), Some(marker.clone()));
let names: Vec<_> = std::fs::read_dir(marker_path(tmp.path()).parent().unwrap())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(names, vec![std::ffi::OsString::from("pending-switch.json")]);
delete(tmp.path()).unwrap();
assert_eq!(read(tmp.path()).unwrap(), None);
delete(tmp.path()).unwrap();
}
#[cfg(unix)]
#[test]
fn pending_switch_marker_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
write(tmp.path(), &PendingSwitch::new("o", &key("k"), NOW)).unwrap();
let mode = std::fs::metadata(marker_path(tmp.path()))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
#[test]
fn pending_switch_write_error_propagates() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("state"), b"not a directory").unwrap();
let err = write(tmp.path(), &PendingSwitch::new("o", &key("k"), NOW));
assert!(err.is_err(), "a marker that did not land must be an error");
}
#[test]
fn pending_switch_unparsable_marker_is_invalid_data() {
let tmp = tempfile::tempdir().unwrap();
let path = marker_path(tmp.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"{not json").unwrap();
let err = read(tmp.path()).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
#[test]
fn pending_switch_delete_retry_gives_up_on_a_persistent_non_sharing_error() {
let tmp = tempfile::tempdir().unwrap();
let path = marker_path(tmp.path());
std::fs::create_dir_all(&path).unwrap();
assert!(delete(tmp.path()).is_err());
}
#[test]
fn pending_switch_sharing_violation_is_windows_only() {
let e = io::Error::from_raw_os_error(32);
assert_eq!(is_sharing_violation(&e), cfg!(windows));
}
}