use sha2::{Digest, Sha256};
use std::fmt;
use std::io::Write as _;
use std::path::Path;
use crate::grammar::{KeyError, is_valid_host_origin};
use crate::profile::OriginSalt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct HostId(String);
impl HostId {
pub fn parse(s: &str) -> Result<Self, KeyError> {
if !is_valid_host_origin(s) {
return Err(KeyError::InvalidHostOrigin(s.to_string()));
}
Ok(HostId(s.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn from_machine_id(machine_id: &str, salt: OriginSalt) -> Self {
let normalized = machine_id.trim().to_ascii_lowercase();
Self::digest(normalized.as_bytes(), salt)
}
pub fn from_hardware_id(hardware_id: &str, salt: OriginSalt) -> Self {
let normalized = hardware_id.trim().to_ascii_lowercase();
Self::digest(normalized.as_bytes(), salt)
}
fn digest(id_bytes: &[u8], salt: OriginSalt) -> Self {
let mut hasher = Sha256::new();
hasher.update(id_bytes);
hasher.update(salt.as_str().as_bytes());
let hex = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
HostId(format!("h-{}", &hex[..12]))
}
pub fn mint(machine_id_path: &Path, fallback_path: &Path, salt: OriginSalt) -> Self {
if let Ok(machine_id) = std::fs::read_to_string(machine_id_path) {
let trimmed = machine_id.trim();
if !trimmed.is_empty() {
return Self::from_machine_id(trimmed, salt);
}
}
Self::mint_persisted(fallback_path)
}
fn mint_persisted(path: &Path) -> Self {
if let Ok(existing) = std::fs::read_to_string(path)
&& let Ok(id) = HostId::parse(existing.trim())
{
return id;
}
let fresh = Self::random();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(mut f) => {
let _ = f.write_all(fresh.as_str().as_bytes());
fresh
}
Err(_) => match std::fs::read_to_string(path) {
Ok(existing) => HostId::parse(existing.trim()).unwrap_or(fresh),
Err(_) => fresh,
},
}
}
fn random() -> Self {
let mut hasher = Sha256::new();
hasher.update(std::process::id().to_le_bytes());
if let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
hasher.update(now.as_nanos().to_le_bytes());
}
if let Ok(hn) = std::env::var("HOSTNAME") {
hasher.update(hn.as_bytes());
}
let hex = hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
HostId(format!("h-{}", &hex[..12]))
}
}
impl fmt::Display for HostId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LocalOrigin(HostId);
impl LocalOrigin {
pub fn from_host_id(id: HostId) -> Self {
LocalOrigin(id)
}
pub fn from_seed(seed: &str, salt: OriginSalt) -> Self {
LocalOrigin(HostId::from_machine_id(seed, salt))
}
pub fn host_id(&self) -> &HostId {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RemoteOrigin(HostId);
impl RemoteOrigin {
pub fn parse(s: &str) -> Result<Self, KeyError> {
HostId::parse(s).map(RemoteOrigin)
}
pub fn from_host(id: HostId) -> Self {
RemoteOrigin(id)
}
pub fn host_id(&self) -> &HostId {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ServiceOrigin(String);
impl ServiceOrigin {
pub fn new(chunk: &str) -> Result<Self, KeyError> {
if crate::grammar::is_valid_verbatim_chunk(chunk) {
Ok(ServiceOrigin(chunk.to_string()))
} else {
Err(KeyError::InvalidVerbatimChunk(chunk.to_string()))
}
}
pub fn catalog() -> Self {
ServiceOrigin(crate::grammar::SERVICE_CATALOG.to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ServiceOrigin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Fleet;
mod sealed {
pub trait Sealed {}
impl Sealed for super::LocalOrigin {}
impl Sealed for super::RemoteOrigin {}
impl Sealed for super::ServiceOrigin {}
}
pub trait ConcreteOrigin: sealed::Sealed {
fn chunk(&self) -> &str;
fn to_origin(&self) -> crate::grammar::Origin;
}
pub trait HostOrigin: ConcreteOrigin {}
impl HostOrigin for LocalOrigin {}
impl HostOrigin for RemoteOrigin {}
impl ConcreteOrigin for LocalOrigin {
fn chunk(&self) -> &str {
self.0.as_str()
}
fn to_origin(&self) -> crate::grammar::Origin {
crate::grammar::Origin::Host(self.0.clone())
}
}
impl ConcreteOrigin for RemoteOrigin {
fn chunk(&self) -> &str {
self.0.as_str()
}
fn to_origin(&self) -> crate::grammar::Origin {
crate::grammar::Origin::Host(self.0.clone())
}
}
impl ConcreteOrigin for ServiceOrigin {
fn chunk(&self) -> &str {
&self.0
}
fn to_origin(&self) -> crate::grammar::Origin {
crate::grammar::Origin::Service(self.clone())
}
}
#[cfg(feature = "serde")]
mod serde_impls {
use super::{HostId, RemoteOrigin};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
impl Serialize for HostId {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for HostId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = String::deserialize(d)?;
HostId::parse(&raw).map_err(D::Error::custom)
}
}
impl Serialize for RemoteOrigin {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.host_id().as_str())
}
}
impl<'de> Deserialize<'de> for RemoteOrigin {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
HostId::deserialize(d).map(RemoteOrigin::from_host)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remote_origin_rejects_wildcards_services_and_junk() {
assert!(RemoteOrigin::parse("h-3fa9c2d41b7e").is_ok());
assert!(RemoteOrigin::parse("*").is_err());
assert!(RemoteOrigin::parse("@catalog").is_err());
assert!(RemoteOrigin::parse("lab-router").is_err());
assert!(RemoteOrigin::parse("h-3fa9c2d41b7").is_err()); assert!(RemoteOrigin::parse("h-3FA9C2D41B7E").is_err()); }
#[test]
fn concrete_origin_chunks_and_bridges() {
let local = LocalOrigin::from_seed("machine-a", OriginSalt::new("example-salt-v1"));
let remote = RemoteOrigin::parse("h-3fa9c2d41b7e").unwrap();
let svc = ServiceOrigin::catalog();
fn chunk_of(o: &impl ConcreteOrigin) -> String {
o.chunk().to_string()
}
assert!(chunk_of(&local).starts_with("h-"));
assert_eq!(chunk_of(&remote), "h-3fa9c2d41b7e");
assert_eq!(chunk_of(&svc), "@catalog");
assert_eq!(
svc.to_origin(),
crate::grammar::Origin::Service(ServiceOrigin::catalog())
);
let parsed = crate::grammar::parse("v1/h-3fa9c2d41b7e/state/tc/health").unwrap();
assert_eq!(parsed.remote_origin(), Some(remote));
let svc_key = crate::grammar::parse("v1/@catalog/state/entity/x").unwrap();
assert_eq!(svc_key.remote_origin(), None);
}
#[test]
fn service_origin_validates_verbatim() {
assert!(ServiceOrigin::new("@desired").is_ok());
assert!(ServiceOrigin::new("desired").is_err());
assert!(ServiceOrigin::new("@Desired").is_err());
}
#[test]
fn rfc_test_vector() {
let id = HostId::from_machine_id(
"b642b4217b34b1e8d3bd915fc65c4452",
OriginSalt::new("example-salt-v1"),
);
assert_eq!(id.as_str(), "h-20609002f7b6");
}
#[test]
fn machine_id_trim_and_case_are_normalized() {
let a = HostId::from_machine_id("b642b4217b34b1e8d3bd915fc65c4452\n", OriginSalt::new("s"));
let b =
HostId::from_machine_id(" B642B4217B34B1E8D3BD915FC65C4452 ", OriginSalt::new("s"));
assert_eq!(a, b);
}
#[test]
fn parse_enforces_shape() {
assert!(HostId::parse("h-20609002f7b6").is_ok());
assert!(HostId::parse("h_20609002f7b6").is_err()); assert!(HostId::parse("h-20609002f7b").is_err()); assert!(HostId::parse("h-20609002F7B6").is_err()); }
#[test]
fn persisted_fallback_is_stable_and_atomic() {
let dir = std::env::temp_dir().join(format!("zsks-test-{}", std::process::id()));
let path = dir.join("host-id");
let _ = std::fs::remove_file(&path);
let first = HostId::mint(
Path::new("/nonexistent/machine-id"),
&path,
OriginSalt::new("s"),
);
let second = HostId::mint(
Path::new("/nonexistent/machine-id"),
&path,
OriginSalt::new("s"),
);
assert_eq!(first, second);
let _ = std::fs::remove_dir_all(&dir);
}
}