use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use clap::{Args, Subcommand};
use net_sdk::org::{
CapabilityAuthorityId, DispatcherScope, GrantRights, GrantTargetScope, OrgCapabilityGrant,
OrgDispatcherGrant, OrgId, OrgKeypair, OrgMembershipCert, OrgRevocationBundle,
ORG_CERT_TTL_SECS_RECOMMENDED,
};
use serde::{Deserialize, Serialize};
use crate::commands::identity::{
enforce_strict_permissions, now_iso8601, parse_entity_hex, read_secret_key_file,
};
use crate::error::{generic, invalid_args, CliError};
use crate::prelude::{emit_value, OutputFormat};
use crate::secret::{zeroize_slice, zeroize_string, ScrubbedBytes, ScrubbedString};
pub(crate) const ORG_FILE_VERSION: u32 = 1;
const GRANT_TTL_SECS_DEFAULT: u64 = 7 * 24 * 60 * 60;
#[derive(Subcommand, Debug)]
pub enum OrgCommand {
Keygen(KeygenArgs),
IssueCert(IssueCertArgs),
IssueFloors(IssueFloorsArgs),
GrantDispatcher(GrantDispatcherArgs),
GrantCapability(GrantCapabilityArgs),
}
#[derive(Args, Debug)]
pub struct KeygenArgs {
#[arg(long)]
pub out: Option<PathBuf>,
#[arg(long)]
pub note: Option<String>,
#[arg(long)]
pub force: bool,
#[arg(long = "accept-windows-dacl")]
pub accept_windows_dacl: bool,
}
#[derive(Args, Debug)]
pub struct IssueCertArgs {
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long)]
pub member: String,
#[arg(long, default_value_t = 0)]
pub generation: u32,
#[arg(long = "ttl-secs", default_value_t = ORG_CERT_TTL_SECS_RECOMMENDED)]
pub ttl_secs: u64,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct IssueFloorsArgs {
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long = "floor", value_name = "MEMBER=GEN", required = true)]
pub floors: Vec<String>,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct GrantDispatcherArgs {
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long)]
pub dispatcher: String,
#[arg(long)]
pub capability: Option<String>,
#[arg(long = "any-capability")]
pub any_capability: bool,
#[arg(long = "ttl-secs", default_value_t = GRANT_TTL_SECS_DEFAULT)]
pub ttl_secs: u64,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
}
#[derive(Args, Debug)]
pub struct GrantCapabilityArgs {
#[arg(long = "org-key", value_name = "PATH")]
pub org_key: PathBuf,
#[arg(long = "grantee-org")]
pub grantee_org: String,
#[arg(long)]
pub capability: String,
#[arg(long)]
pub invoke: bool,
#[arg(long)]
pub discover: bool,
#[arg(long = "target-node")]
pub target_node: Option<String>,
#[arg(long = "target-any-owned-by")]
pub target_any_owned_by: Option<String>,
#[arg(long = "ttl-secs", default_value_t = GRANT_TTL_SECS_DEFAULT)]
pub ttl_secs: u64,
#[arg(long)]
pub out: PathBuf,
#[arg(long = "audience-out", value_name = "PATH")]
pub audience_out: Option<PathBuf>,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub insecure_permissions: bool,
#[arg(long = "accept-windows-dacl")]
pub accept_windows_dacl: bool,
}
pub async fn run(cmd: OrgCommand, output: Option<OutputFormat>) -> Result<(), CliError> {
match cmd {
OrgCommand::Keygen(args) => run_keygen(args, output).await,
OrgCommand::IssueCert(args) => run_issue_cert(args, output).await,
OrgCommand::IssueFloors(args) => run_issue_floors(args, output).await,
OrgCommand::GrantDispatcher(args) => run_grant_dispatcher(args, output).await,
OrgCommand::GrantCapability(args) => run_grant_capability(args, output).await,
}
}
async fn run_keygen(args: KeygenArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let keypair = OrgKeypair::generate();
let org_id_hex = hex::encode(keypair.org_id().as_bytes());
let path = match args.out {
Some(explicit) => explicit,
None => default_org_key_path(&org_id_hex).ok_or_else(|| {
invalid_args(
"cannot resolve the platform config directory, and refusing to fall back to \
the working directory — this file holds the ORG ROOT SEED. Pass an explicit \
--out."
.to_string(),
)
})?,
};
refuse_existing(&path, args.force).await?;
if args.force {
refuse_replacing_foreign_seed(&path, SeedArtifact::OrgKey).await?;
}
let file = OrgKeyFile {
org_id_hex: org_id_hex.clone(),
seed_hex: hex::encode(keypair.secret_bytes()),
created_at: now_iso8601(),
note: args.note.clone(),
};
let toml_text = ScrubbedString::new(
toml::to_string_pretty(&file)
.map_err(|e| generic(format!("failed to serialize org key TOML: {e}")))?,
);
let tmp = stage_beside(&path, toml_text.as_bytes(), true).await?;
if args.force {
publish_staged_replace(&tmp, &path).await?;
} else {
publish_staged(&tmp, &path).await?;
}
enforce_strict_permissions(&path).await?;
warn_secret_permissions(&path, args.accept_windows_dacl);
let summary = OrgKeySummary {
path: path.display().to_string(),
org_id_hex,
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_issue_cert(args: IssueCertArgs, output: Option<OutputFormat>) -> Result<(), CliError> {
let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
let member = parse_entity_hex(&args.member)?;
let cert =
OrgMembershipCert::try_issue(&keypair, member.clone(), args.generation, args.ttl_secs)
.map_err(|e| invalid_args(format!("issue-cert: {e}")))?;
refuse_aliased_paths(&[("--org-key", &args.org_key), ("--out", &args.out)])?;
refuse_existing(&args.out, args.force).await?;
let json = serialize_json(&OrgCertFile {
version: ORG_FILE_VERSION,
cert: cert.clone(),
})?;
publish_json_artifact(&args.out, &json, args.force).await?;
let summary = IssueCertOutput {
path: args.out.display().to_string(),
org_id_hex: hex::encode(cert.org_id.as_bytes()),
member_hex: hex::encode(member.as_bytes()),
generation: cert.generation,
not_before: cert.not_before,
not_after: cert.not_after,
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_issue_floors(
args: IssueFloorsArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
let mut floors = BTreeMap::new();
for raw in &args.floors {
let (member_raw, gen_raw) = raw.split_once('=').ok_or_else(|| {
invalid_args(format!("--floor '{raw}' must be <member-hex>=<generation>"))
})?;
let member = parse_entity_hex(member_raw)?;
let generation: u32 = gen_raw
.parse()
.map_err(|e| invalid_args(format!("--floor '{raw}': generation must be a u32: {e}")))?;
if floors.insert(member, generation).is_some() {
return Err(invalid_args(format!(
"--floor lists member {member_raw} more than once"
)));
}
}
let bundle = OrgRevocationBundle::try_issue(&keypair, &floors)
.map_err(|e| invalid_args(format!("issue-floors: {e}")))?;
refuse_aliased_paths(&[("--org-key", &args.org_key), ("--out", &args.out)])?;
refuse_existing(&args.out, args.force).await?;
let json = serialize_json(&OrgFloorsFile {
version: ORG_FILE_VERSION,
bundle: bundle.clone(),
})?;
publish_json_artifact(&args.out, &json, args.force).await?;
let summary = IssueFloorsOutput {
path: args.out.display().to_string(),
org_id_hex: hex::encode(bundle.org_id.as_bytes()),
floors: bundle.floors().len(),
issued_at: bundle.issued_at,
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_grant_dispatcher(
args: GrantDispatcherArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
refuse_force(args.force)?;
let keypair = load_org_key(&args.org_key, args.insecure_permissions).await?;
let dispatcher = parse_entity_hex(&args.dispatcher)?;
let (scope, capability_label) = match (&args.capability, args.any_capability) {
(Some(tag), false) => (
DispatcherScope::Exact(CapabilityAuthorityId::for_tag(tag)),
tag.clone(),
),
(None, true) => (DispatcherScope::Any, "any".to_string()),
(Some(_), true) => {
return Err(invalid_args(
"--capability and --any-capability are mutually exclusive",
))
}
(None, false) => {
return Err(invalid_args(
"one of --capability <tag> or --any-capability is required",
))
}
};
let grant = OrgDispatcherGrant::try_issue(&keypair, dispatcher.clone(), scope, args.ttl_secs)
.map_err(|e| invalid_args(format!("grant-dispatcher: {e}")))?;
refuse_aliased_paths(&[("--org-key", &args.org_key), ("--out", &args.out)])?;
let json = serialize_json(&OrgDispatcherGrantFile {
version: ORG_FILE_VERSION,
grant: grant.clone(),
})?;
let tmp = stage_beside(&args.out, &json, false).await?;
publish_staged(&tmp, &args.out).await?;
let summary = GrantDispatcherOutput {
path: args.out.display().to_string(),
org_id_hex: hex::encode(grant.org_id.as_bytes()),
dispatcher_hex: hex::encode(dispatcher.as_bytes()),
capability: capability_label,
not_before: grant.not_before,
not_after: grant.not_after,
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
async fn run_grant_capability(
args: GrantCapabilityArgs,
output: Option<OutputFormat>,
) -> Result<(), CliError> {
refuse_force(args.force)?;
let issuer = load_org_key(&args.org_key, args.insecure_permissions).await?;
let grantee_org = parse_org_hex(&args.grantee_org)?;
let capability = CapabilityAuthorityId::for_tag(&args.capability);
let rights = match (args.invoke, args.discover) {
(false, false) => {
return Err(invalid_args(
"at least one of --invoke or --discover is required",
))
}
(true, false) => GrantRights::INVOKE,
(false, true) => GrantRights::DISCOVER,
(true, true) => GrantRights::INVOKE.union(GrantRights::DISCOVER),
};
let mut rights_labels = Vec::new();
if args.invoke {
rights_labels.push("invoke");
}
if args.discover {
rights_labels.push("discover");
}
match (args.discover, &args.audience_out) {
(true, None) => return Err(invalid_args(
"--discover requires --audience-out <PATH> (where to write the minted audience secret)",
)),
(false, Some(_)) => {
return Err(invalid_args(
"--audience-out is only valid with --discover (no secret is minted otherwise)",
))
}
_ => {}
}
let mut alias_paths: Vec<(&str, &Path)> = vec![
("--org-key", args.org_key.as_path()),
("--out", args.out.as_path()),
];
if let Some(audience_out) = &args.audience_out {
alias_paths.push(("--audience-out", audience_out.as_path()));
}
refuse_aliased_paths(&alias_paths)?;
let (target_scope, target_label) = match (&args.target_node, &args.target_any_owned_by) {
(Some(entity_hex), None) => {
let entity = parse_entity_hex(entity_hex)?;
let label = format!("node:{}", hex::encode(entity.as_bytes()));
(GrantTargetScope::ExactNode(entity), label)
}
(None, Some(org_hex)) => {
let org = parse_org_hex(org_hex)?;
let label = format!("any-owned-by:{}", hex::encode(org.as_bytes()));
(GrantTargetScope::AnyNodeOwnedBy(org), label)
}
(Some(_), Some(_)) => {
return Err(invalid_args(
"--target-node and --target-any-owned-by are mutually exclusive",
))
}
(None, None) => {
return Err(invalid_args(
"one of --target-node <entity> or --target-any-owned-by <org> is required",
))
}
};
let (grant, secret) = OrgCapabilityGrant::try_issue(
&issuer,
grantee_org,
capability,
rights,
target_scope,
args.ttl_secs,
)
.map_err(|e| invalid_args(format!("grant-capability: {e}")))?;
let grant_json = serialize_json(&OrgCapabilityGrantFile {
version: ORG_FILE_VERSION,
grant: grant.clone(),
})?;
let audience_out_label = match secret {
Some(secret) => {
let audience_out = args
.audience_out
.as_ref()
.expect("--discover requires --audience-out (validated above)");
let mut raw = secret.encode_config();
let secret_bytes = ScrubbedBytes::new(raw.to_vec());
zeroize_slice(&mut raw);
let grant_tmp = stage_beside(&args.out, &grant_json, false).await?;
let secret_tmp = match stage_beside(audience_out, secret_bytes.as_slice(), true).await {
Ok(t) => t,
Err(e) => {
remove_file_or_warn(&grant_tmp, "staging temp").await;
return Err(e);
}
};
drop(secret_bytes);
if let Err(e) = publish_staged(&grant_tmp, &args.out).await {
remove_file_or_warn(
&secret_tmp,
"staging temp (holds a copy of the audience secret)",
)
.await;
return Err(e);
}
if let Err(e) = publish_staged(&secret_tmp, audience_out).await {
remove_file_or_warn(
&args.out,
"grant (rollback: its audience secret failed to publish)",
)
.await;
return Err(e);
}
warn_secret_permissions(audience_out, args.accept_windows_dacl);
Some(audience_out.display().to_string())
}
None => {
let tmp = stage_beside(&args.out, &grant_json, false).await?;
publish_staged(&tmp, &args.out).await?;
None
}
};
let summary = GrantCapabilityOutput {
path: args.out.display().to_string(),
audience_out: audience_out_label,
grant_id_hex: hex::encode(grant.grant_id),
issuer_org_hex: hex::encode(grant.issuer_org.as_bytes()),
grantee_org_hex: hex::encode(grant.grantee_org.as_bytes()),
capability: args.capability.clone(),
rights: rights_labels.join(","),
target: target_label,
not_before: grant.not_before,
not_after: grant.not_after,
};
emit_value(OutputFormat::resolve_oneshot(output), &summary)
.map_err(|e| generic(format!("write summary: {e}")))?;
Ok(())
}
#[derive(Serialize, Deserialize)]
struct OrgKeyFile {
org_id_hex: String,
seed_hex: String,
created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
impl Drop for OrgKeyFile {
fn drop(&mut self) {
zeroize_string(&mut self.seed_hex);
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgCertFile {
pub(crate) version: u32,
pub(crate) cert: OrgMembershipCert,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgFloorsFile {
pub(crate) version: u32,
pub(crate) bundle: OrgRevocationBundle,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgDispatcherGrantFile {
pub(crate) version: u32,
pub(crate) grant: OrgDispatcherGrant,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct OrgCapabilityGrantFile {
pub(crate) version: u32,
pub(crate) grant: OrgCapabilityGrant,
}
#[derive(Debug, Serialize)]
struct OrgKeySummary {
path: String,
org_id_hex: String,
created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
#[derive(Debug, Serialize)]
struct IssueCertOutput {
path: String,
org_id_hex: String,
member_hex: String,
generation: u32,
not_before: u64,
not_after: u64,
}
#[derive(Debug, Serialize)]
struct IssueFloorsOutput {
path: String,
org_id_hex: String,
floors: usize,
issued_at: u64,
}
#[derive(Debug, Serialize)]
struct GrantDispatcherOutput {
path: String,
org_id_hex: String,
dispatcher_hex: String,
capability: String,
not_before: u64,
not_after: u64,
}
#[derive(Debug, Serialize)]
struct GrantCapabilityOutput {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
audience_out: Option<String>,
grant_id_hex: String,
issuer_org_hex: String,
grantee_org_hex: String,
capability: String,
rights: String,
target: String,
not_before: u64,
not_after: u64,
}
fn refuse_force(force: bool) -> Result<(), CliError> {
if force {
return Err(invalid_args(
"--force is refused for grant commands: publication is no-clobber (a forced replace \
is not crash-atomic and, on a case-insensitive filesystem, an aliased output could \
destroy the org key). Write to a fresh path, or remove the old artifact explicitly.",
));
}
Ok(())
}
async fn load_org_key(path: &Path, insecure_permissions: bool) -> Result<OrgKeypair, CliError> {
let mut text = read_secret_key_file(path, "org key file", insecure_permissions).await?;
let outcome = load_org_key_from_text(&text, path);
zeroize_string(&mut text);
outcome
}
fn load_org_key_from_text(text: &str, path: &Path) -> Result<OrgKeypair, CliError> {
let parsed: OrgKeyFile = toml::from_str(text).map_err(|_| {
invalid_args(format!(
"org key file {} is not valid TOML (kind: parse_error)",
path.display()
))
})?;
let seed_bytes = ScrubbedBytes::new(hex::decode(parsed.seed_hex.as_bytes()).map_err(|_| {
invalid_args(format!(
"org key file {} seed_hex is not valid hex (kind: bad_seed_encoding)",
path.display()
))
})?);
if seed_bytes.as_slice().len() != 32 {
return Err(invalid_args(format!(
"org key file {} seed must be 32 bytes (64 hex chars), got {} (kind: bad_seed_length)",
path.display(),
seed_bytes.as_slice().len()
)));
}
let mut seed = [0u8; 32];
seed.copy_from_slice(seed_bytes.as_slice());
let keypair = OrgKeypair::from_bytes(seed);
zeroize_slice(&mut seed);
let derived = hex::encode(keypair.org_id().as_bytes());
if !parsed.org_id_hex.eq_ignore_ascii_case(&derived) {
return Err(invalid_args(format!(
"org key file {}: org_id_hex does not match the key derived from seed_hex",
path.display()
)));
}
Ok(keypair)
}
fn parse_org_hex(s: &str) -> Result<OrgId, CliError> {
let s = s.strip_prefix("0x").unwrap_or(s);
let bytes =
hex::decode(s).map_err(|e| invalid_args(format!("org id is not valid hex: {e}")))?;
let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
invalid_args(format!(
"org id must be 32 bytes (64 hex chars), got {}",
bytes.len()
))
})?;
Ok(OrgId::from_bytes(arr))
}
fn normalize_for_alias(p: &Path) -> PathBuf {
std::path::absolute(p)
.unwrap_or_else(|_| p.to_path_buf())
.components()
.collect()
}
pub(crate) fn refuse_aliased_paths(paths: &[(&str, &Path)]) -> Result<(), CliError> {
for i in 0..paths.len() {
for j in (i + 1)..paths.len() {
if normalize_for_alias(paths[i].1) == normalize_for_alias(paths[j].1) {
return Err(invalid_args(format!(
"{} and {} resolve to the same path; refusing to alias them",
paths[i].0, paths[j].0
)));
}
}
}
Ok(())
}
fn serialize_json<T: Serialize>(value: &T) -> Result<Vec<u8>, CliError> {
serde_json::to_vec_pretty(value).map_err(|e| generic(format!("failed to serialize: {e}")))
}
fn stage_nonce() -> String {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{pid}.{nanos}")
}
pub(crate) async fn stage_beside(
final_path: &Path,
bytes: &[u8],
secret: bool,
) -> Result<PathBuf, CliError> {
if let Some(parent) = final_path.parent() {
if !parent.as_os_str().is_empty() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
generic(format!(
"failed to create parent directory {}: {e}",
parent.display()
))
})?;
}
}
let file_name = final_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("artifact");
let tmp = final_path.with_file_name(format!(".{file_name}.stage.{}", stage_nonce()));
let tmp_owned = tmp.clone();
let payload = ScrubbedBytes::new(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(if secret { 0o600 } else { 0o644 });
}
#[cfg(not(unix))]
{
let _ = secret;
}
let mut f = opts.open(&tmp_owned)?;
let written = (|| -> std::io::Result<()> {
std::io::Write::write_all(&mut f, payload.as_slice())?;
f.sync_all()
})();
if let Err(e) = written {
drop(f);
let _ = std::fs::remove_file(&tmp_owned);
return Err(e);
}
Ok(())
})
.await
.map_err(|e| generic(format!("stage-write task panicked: {e}")))?
.map_err(|e| generic(format!("failed to stage {}: {e}", tmp.display())))?;
Ok(tmp)
}
pub(crate) async fn publish_staged(tmp: &Path, final_path: &Path) -> Result<(), CliError> {
let tmp_owned = tmp.to_path_buf();
let final_owned = final_path.to_path_buf();
let link = tokio::task::spawn_blocking(move || std::fs::hard_link(&tmp_owned, &final_owned))
.await
.map_err(|e| generic(format!("publish task panicked: {e}")))?;
if let Err(e) = link {
remove_file_or_warn(tmp, "staging temp").await;
return Err(if e.kind() == std::io::ErrorKind::AlreadyExists {
invalid_args(format!(
"file already exists at {}; publication is no-clobber — write to a fresh path \
or remove the old artifact explicitly",
final_path.display()
))
} else {
generic(format!("failed to publish {}: {e}", final_path.display()))
});
}
remove_file_or_warn(tmp, "staging temp").await;
sync_parent_dir(final_path).await;
Ok(())
}
async fn remove_file_or_warn(path: &Path, what: &str) {
match tokio::fs::remove_file(path).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => eprintln!(
"warning: failed to remove {what} {}: {e}; remove it manually.",
path.display()
),
}
}
#[cfg(not(unix))]
pub(crate) fn warn_secret_permissions(path: &Path, accepted: bool) {
if !accepted {
eprintln!(
"warning: the 0600 audience-secret mode is not enforced on Windows; {} inherits its \
parent directory's NTFS DACL. Ensure the parent is owner-only (or pass \
--accept-windows-dacl to silence).",
path.display()
);
}
}
#[cfg(unix)]
pub(crate) fn warn_secret_permissions(_path: &Path, _accepted: bool) {}
async fn sync_parent_dir(path: &Path) {
#[cfg(unix)]
{
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
}
#[cfg(not(unix))]
{
let _ = path;
}
}
pub(crate) async fn refuse_existing(path: &Path, force: bool) -> Result<(), CliError> {
if force {
return Ok(());
}
match tokio::fs::try_exists(path).await {
Ok(true) => Err(invalid_args(format!(
"file already exists at {}; pass --force to overwrite",
path.display()
))),
Ok(false) => Ok(()),
Err(e) => Err(generic(format!("failed to stat {}: {e}", path.display()))),
}
}
async fn publish_json_artifact(
final_path: &Path,
json: &[u8],
force: bool,
) -> Result<(), CliError> {
if force {
refuse_replacing_foreign_seed(final_path, SeedArtifact::None).await?;
}
let tmp = stage_beside(final_path, json, false).await?;
if force {
publish_staged_replace(&tmp, final_path).await
} else {
publish_staged(&tmp, final_path).await
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum SeedArtifact {
None,
OrgKey,
Identity,
SubnetKey,
}
impl SeedArtifact {
fn describe(self) -> &'static str {
match self {
SeedArtifact::None => "no seed material",
SeedArtifact::OrgKey => "org root key material",
SeedArtifact::Identity => "operator identity key material",
SeedArtifact::SubnetKey => "subnet authority key material",
}
}
}
pub(crate) async fn classify_seed_artifact(path: &Path) -> SeedArtifact {
let Ok(mut text) = tokio::fs::read_to_string(path).await else {
return SeedArtifact::None;
};
let parsed = toml::from_str::<toml::Value>(&text).ok();
let has_seed = parsed
.as_ref()
.and_then(|v| v.get("seed_hex"))
.is_some_and(|v| v.is_str());
let has_org_id = parsed
.as_ref()
.and_then(|v| v.get("org_id_hex"))
.is_some_and(|v| v.is_str());
let is_subnet_key = parsed
.as_ref()
.and_then(|v| v.get("kind"))
.and_then(|v| v.as_str())
.is_some_and(|k| k == "subnet-authority-key");
zeroize_string(&mut text);
match (has_seed, has_org_id, is_subnet_key) {
(true, true, _) => SeedArtifact::OrgKey,
(true, false, true) => SeedArtifact::SubnetKey,
(true, false, false) => SeedArtifact::Identity,
_ => SeedArtifact::None,
}
}
pub(crate) async fn refuse_replacing_foreign_seed(
path: &Path,
publishing: SeedArtifact,
) -> Result<(), CliError> {
let found = classify_seed_artifact(path).await;
if found == SeedArtifact::None || found == publishing {
return Ok(());
}
Err(invalid_args(format!(
"refusing to overwrite {}: it contains {}. --out must not name that file, however the \
path is spelled (case variant, symlink, hard link, or relative path). If you really \
mean to discard it, remove it explicitly first.",
path.display(),
found.describe(),
)))
}
pub(crate) async fn publish_staged_replace(tmp: &Path, final_path: &Path) -> Result<(), CliError> {
let tmp_owned = tmp.to_path_buf();
let final_owned = final_path.to_path_buf();
let renamed = tokio::task::spawn_blocking(move || std::fs::rename(&tmp_owned, &final_owned))
.await
.map_err(|e| generic(format!("publish task panicked: {e}")))?;
if let Err(e) = renamed {
remove_file_or_warn(tmp, "staging temp").await;
return Err(generic(format!(
"failed to publish {}: {e}",
final_path.display()
)));
}
sync_parent_dir(final_path).await;
Ok(())
}
fn default_org_key_path(org_id_hex: &str) -> Option<PathBuf> {
let short = &org_id_hex[..org_id_hex.len().min(16)];
Some(
dirs::config_dir()?
.join("net-mesh")
.join("orgs")
.join(format!("org-{short}.toml")),
)
}