use std::path::{Path, PathBuf};
use clap::{Args, Subcommand};
use serde::{Deserialize, Serialize};
use crate::error::{generic, invalid_args, sdk, CliError};
use crate::prelude::{emit_value, OutputFormat};
#[derive(Subcommand, Debug)]
pub enum IdentityCommand {
Generate(GenerateArgs),
Show(ShowArgs),
Fingerprint(FingerprintArgs),
}
#[derive(Args, Debug)]
pub struct GenerateArgs {
#[arg(long)]
pub out: Option<PathBuf>,
#[arg(long)]
pub note: Option<String>,
#[arg(long)]
pub force: bool,
}
#[derive(Args, Debug)]
pub struct ShowArgs {
pub path: PathBuf,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct FingerprintArgs {
pub path: PathBuf,
#[arg(long)]
pub insecure_permissions: bool,
}
pub async fn run(cmd: IdentityCommand, output: Option<OutputFormat>) -> Result<(), CliError> {
match cmd {
IdentityCommand::Generate(args) => run_generate(args, output).await,
IdentityCommand::Show(args) => run_show(args, output).await,
IdentityCommand::Fingerprint(args) => run_fingerprint(args, output).await,
}
}
async fn run_generate(args: GenerateArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
use net_sdk::deck::OperatorIdentity;
let identity = OperatorIdentity::generate();
let operator_id = identity.operator_id();
let seed = *identity.keypair().secret_bytes();
let public_key = *identity.keypair().entity_id().as_bytes();
let path = args
.out
.unwrap_or_else(|| default_identity_path(operator_id));
if !args.force {
match tokio::fs::try_exists(&path).await {
Ok(true) => {
return Err(invalid_args(format!(
"identity file already exists at {}; pass --force to overwrite",
path.display()
)));
}
Ok(false) => {}
Err(e) => {
return Err(generic(format!(
"failed to stat {}: {e}; pass --force to override",
path.display()
)));
}
}
}
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
generic(format!(
"failed to create parent directory {}: {e}",
parent.display()
))
})?;
}
let file = IdentityFile {
operator_id_hex: format!("0x{operator_id:016x}"),
seed_hex: hex::encode(seed),
public_key_hex: hex::encode(public_key),
created_at: now_iso8601(),
note: args.note.clone(),
};
let toml_text = toml::to_string_pretty(&file)
.map_err(|e| generic(format!("failed to serialize identity TOML: {e}")))?;
let pid = std::process::id();
let tmp = path.with_extension(format!("tmp.{pid}"));
write_identity_atomically(&tmp, &path, toml_text.as_bytes()).await?;
enforce_strict_permissions(&path).await?;
let summary = IdentitySummary {
path: path.display().to_string(),
operator_id_hex: file.operator_id_hex.clone(),
public_key_hex: file.public_key_hex.clone(),
created_at: file.created_at.clone(),
note: file.note.clone(),
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_show(args: ShowArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let file = read_identity_file(&args.path, args.insecure_permissions).await?;
let summary = IdentitySummary {
path: args.path.display().to_string(),
operator_id_hex: file.operator_id_hex.clone(),
public_key_hex: file.public_key_hex.clone(),
created_at: file.created_at.clone(),
note: file.note.clone(),
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_fingerprint(
args: FingerprintArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
use sha2::{Digest, Sha256};
let file = read_identity_file(&args.path, args.insecure_permissions).await?;
let public_key = hex::decode(&file.public_key_hex)
.map_err(|e| sdk(format!("public_key_hex is not valid hex: {e}")))?;
let digest = Sha256::digest(&public_key);
let short: Vec<String> = digest.iter().take(8).map(|b| format!("{b:02X}")).collect();
let fingerprint = short.join(":");
let info = FingerprintOutput {
operator_id_hex: file.operator_id_hex.clone(),
fingerprint,
};
emit_value(OutputFormat::resolve_oneshot(output), &info)
.map_err(|e| generic(format!("write fingerprint: {e}")))?;
Ok(())
}
#[derive(Debug, Serialize, Deserialize)]
struct IdentityFile {
operator_id_hex: String,
seed_hex: String,
public_key_hex: String,
created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
#[derive(Debug, Serialize)]
struct IdentitySummary {
path: String,
operator_id_hex: String,
public_key_hex: String,
created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
#[derive(Debug, Serialize)]
struct FingerprintOutput {
operator_id_hex: String,
fingerprint: String,
}
async fn read_identity_file(
path: &Path,
insecure_permissions: bool,
) -> Result<IdentityFile, CliError> {
if !insecure_permissions {
check_strict_permissions(path).await?;
}
let text = tokio::fs::read_to_string(path).await.map_err(|e| {
generic(format!(
"failed to read identity file {}: {e}",
path.display()
))
})?;
let parsed: IdentityFile = toml::from_str(&text).map_err(|e| {
invalid_args(format!(
"identity file {} failed to parse: {e}",
path.display()
))
})?;
Ok(parsed)
}
async fn write_identity_atomically(
tmp: &Path,
final_path: &Path,
bytes: &[u8],
) -> Result<(), CliError> {
let tmp_owned = tmp.to_path_buf();
let bytes_owned = bytes.to_vec();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&tmp_owned)?;
std::io::Write::write_all(&mut f, &bytes_owned)?;
f.sync_all()?;
Ok(())
})
.await
.map_err(|e| generic(format!("seed-write task panicked: {e}")))?
.map_err(|e| {
generic(format!(
"failed to write identity tmp {}: {e}",
tmp.display()
))
})?;
tokio::fs::rename(tmp, final_path).await.map_err(|e| {
let tmp_for_cleanup = tmp.to_path_buf();
tokio::spawn(async move {
let _ = tokio::fs::remove_file(tmp_for_cleanup).await;
});
generic(format!(
"rename identity tmp {} -> {}: {e}",
tmp.display(),
final_path.display()
))
})?;
Ok(())
}
#[cfg(unix)]
async fn enforce_strict_permissions(path: &Path) -> Result<(), CliError> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(path, perms).await.map_err(|e| {
generic(format!(
"failed to set 0600 permissions on {}: {e}",
path.display()
))
})
}
#[cfg(not(unix))]
async fn enforce_strict_permissions(_path: &Path) -> Result<(), CliError> {
Ok(())
}
#[cfg(unix)]
async fn check_strict_permissions(path: &Path) -> Result<(), CliError> {
use std::os::unix::fs::PermissionsExt;
let meta = tokio::fs::metadata(path).await.map_err(|e| {
generic(format!(
"failed to stat identity file {}: {e}",
path.display()
))
})?;
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
return Err(invalid_args(format!(
"identity file {} has permissive mode {:#o}; tighten to 0600 \
or pass --insecure-permissions to override (kind: \
permissive_mode)",
path.display(),
mode
)));
}
Ok(())
}
#[cfg(not(unix))]
async fn check_strict_permissions(path: &Path) -> Result<(), CliError> {
eprintln!(
"warning: identity-file permission gate is a no-op on Windows; \
NTFS ACLs on {} are not validated. Pass --insecure-permissions \
to silence, or manage the DACL out-of-band.",
path.display()
);
Ok(())
}
fn default_identity_path(operator_id: u64) -> PathBuf {
let base = dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("net-mesh")
.join("identities");
base.join(format!("operator-0x{operator_id:016x}.toml"))
}
fn now_iso8601() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format_iso8601_utc(now)
}
fn format_iso8601_utc(secs_since_epoch: u64) -> String {
const SECONDS_PER_DAY: u64 = 86_400;
let days = (secs_since_epoch / SECONDS_PER_DAY) as i64;
let remainder = secs_since_epoch % SECONDS_PER_DAY;
let hour = (remainder / 3600) as u32;
let minute = ((remainder % 3600) / 60) as u32;
let second = (remainder % 60) as u32;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if m <= 2 { y + 1 } else { y };
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, m, d, hour, minute, second
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn iso8601_formats_unix_epoch() {
assert_eq!(format_iso8601_utc(0), "1970-01-01T00:00:00Z");
}
#[test]
fn iso8601_formats_known_timestamp() {
assert_eq!(format_iso8601_utc(1763382896), "2025-11-17T12:34:56Z");
}
}