use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::config::SigningConfig;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profile {
pub did_key_id: String,
pub vta_did: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profiles {
#[serde(default)]
pub profiles: BTreeMap<String, Profile>,
}
impl Profiles {
pub fn default_path() -> Result<PathBuf> {
let global = SigningConfig::default_global_path()?;
Ok(global
.parent()
.context("global config path has no parent directory")?
.join("profiles.json"))
}
pub fn load_from(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(data) => serde_json::from_str(&data)
.with_context(|| format!("failed to parse {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
}
}
pub fn load() -> Result<Self> {
Self::load_from(&Self::default_path()?)
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let data = serde_json::to_string_pretty(self)?;
std::fs::write(path, data).with_context(|| format!("failed to write {}", path.display()))
}
pub fn save(&self) -> Result<()> {
self.save_to(&Self::default_path()?)
}
pub fn get(&self, name: &str) -> Result<&Profile> {
self.profiles.get(name).with_context(|| {
if self.profiles.is_empty() {
format!(
"no profile '{name}': there are none yet. Create one with \
`did-git-sign init --profile {name} …`"
)
} else {
let names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
format!("no profile '{name}'. Known profiles: {}", names.join(", "))
}
})
}
pub fn name_of(&self, did_key_id: &str) -> Option<&str> {
self.profiles
.iter()
.find(|(_, p)| p.did_key_id == did_key_id)
.map(|(n, _)| n.as_str())
}
}
pub fn validate_name(name: &str) -> Result<()> {
let mut chars = name.chars();
let ok_first = chars
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
let ok_rest =
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
if !ok_first || !ok_rest || name.len() > 64 {
bail!(
"invalid profile name '{name}': use a lowercase letter or digit, then lowercase \
letters, digits, '-', '_' or '.' (at most 64 characters)"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn bob() -> Profile {
Profile {
did_key_id: "did:webvh:QmBob:example.com:bob#key-0".into(),
vta_did: "did:webvh:QmVta:example.com:vta".into(),
context: Some("bob".into()),
}
}
#[test]
fn names_are_lowercase_slugs() {
for ok in ["bob", "carol-2", "a", "x.y_z", "0day"] {
assert!(validate_name(ok).is_ok(), "{ok} should be valid");
}
for bad in [
"",
"Bob",
"-bob",
"bob carol",
"bob/carol",
"é",
&"a".repeat(65),
] {
assert!(validate_name(bad).is_err(), "{bad:?} should be refused");
}
}
#[test]
fn a_missing_file_is_an_empty_set() {
let dir = tempfile::tempdir().unwrap();
let p = Profiles::load_from(&dir.path().join("profiles.json")).unwrap();
assert!(p.profiles.is_empty());
}
#[test]
fn save_then_load_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("profiles.json");
let mut p = Profiles::default();
p.profiles.insert("bob".into(), bob());
p.save_to(&path).unwrap();
assert_eq!(Profiles::load_from(&path).unwrap(), p);
}
#[test]
fn get_names_the_known_profiles_when_one_is_missing() {
let mut p = Profiles::default();
assert!(p.get("bob").unwrap_err().to_string().contains("none yet"));
p.profiles.insert("bob".into(), bob());
let err = p.get("carol").unwrap_err().to_string();
assert!(err.contains("Known profiles: bob"), "{err}");
assert_eq!(p.get("bob").unwrap(), &bob());
}
#[test]
fn name_of_finds_a_profile_by_identity() {
let mut p = Profiles::default();
p.profiles.insert("bob".into(), bob());
assert_eq!(p.name_of(&bob().did_key_id), Some("bob"));
assert_eq!(p.name_of("did:webvh:other#key-0"), None);
}
#[test]
fn context_is_optional_on_the_wire() {
let json = r#"{"profiles":{"bob":{"did_key_id":"did:x#key-0","vta_did":"did:v"}}}"#;
let p: Profiles = serde_json::from_str(json).unwrap();
assert_eq!(p.profiles["bob"].context, None);
}
}