use std::path::{Path, PathBuf};
use didwebvh_rs::DIDWebVHState;
use didwebvh_rs::log_entry::LogEntry;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallRefusal {
InvalidLog(String),
WrongDid { expected: String, found: String },
NotAnExtension(String),
NotServedHere(String),
}
impl std::fmt::Display for InstallRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidLog(why) => write!(f, "the log does not verify as did:webvh: {why}"),
Self::WrongDid { expected, found } => {
write!(
f,
"the log is for {found}, not this community's DID {expected}"
)
}
Self::NotAnExtension(why) => write!(
f,
"the log does not keep every entry this community serves, unchanged and in \
order: {why}"
),
Self::NotServedHere(why) => {
write!(
f,
"this community does not serve its own did:webvh log: {why}"
)
}
}
}
}
impl std::error::Error for InstallRefusal {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Accepted {
pub did: String,
pub version_id: String,
pub previous_version_id: String,
pub entries_added: usize,
pub entry_count: usize,
pub created: Option<String>,
}
fn lines(log: &str) -> Vec<&str> {
let body = log.strip_suffix('\n').unwrap_or(log);
let body = body.strip_suffix('\r').unwrap_or(body);
if body.is_empty() {
return Vec::new();
}
body.split('\n')
.map(|l| l.strip_suffix('\r').unwrap_or(l))
.collect()
}
pub fn check(did: &str, served: &str, supplied: &str) -> Result<Accepted, InstallRefusal> {
let supplied_lines = lines(supplied);
if supplied_lines.is_empty() {
return Err(InstallRefusal::InvalidLog("the log has no entries".into()));
}
let mut entries = Vec::with_capacity(supplied_lines.len());
let mut version = None;
for (i, line) in supplied_lines.iter().enumerate() {
let entry = LogEntry::deserialize_string(line, version)
.map_err(|e| InstallRefusal::InvalidLog(format!("line {}: {e}", i + 1)))?;
version = Some(entry.get_webvh_version());
entries.push(entry);
}
let mut state = DIDWebVHState::from_log_entries(entries);
state
.validate()
.map_err(|e| InstallRefusal::InvalidLog(e.to_string()))?
.assert_complete()
.map_err(|e| InstallRefusal::InvalidLog(e.to_string()))?;
if state.log_entries().len() != supplied_lines.len() {
return Err(InstallRefusal::InvalidLog(format!(
"{} of {} entries verified",
state.log_entries().len(),
supplied_lines.len()
)));
}
let last = state
.log_entries()
.last()
.ok_or_else(|| InstallRefusal::InvalidLog("the log has no entries".into()))?;
let found = last
.get_state()
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| InstallRefusal::InvalidLog("the last entry's document has no id".into()))?;
if found != did {
return Err(InstallRefusal::WrongDid {
expected: did.to_string(),
found: found.to_string(),
});
}
let served_lines = lines(served);
if served_lines.len() > supplied_lines.len() {
return Err(InstallRefusal::NotAnExtension(format!(
"it has {} entries, and {} are served",
supplied_lines.len(),
served_lines.len()
)));
}
if let Some(i) = served_lines
.iter()
.zip(&supplied_lines)
.position(|(a, b)| a != b)
{
return Err(InstallRefusal::NotAnExtension(format!(
"entry {} differs from the one served",
i + 1
)));
}
let version_of = |line: &str| -> String {
serde_json::from_str::<serde_json::Value>(line)
.ok()
.and_then(|v| {
v.get("versionId")
.and_then(|s| s.as_str())
.map(String::from)
})
.unwrap_or_default()
};
let version_id = last.get_version_id().to_string();
let previous_version_id = served_lines
.last()
.map(|l| version_of(l))
.filter(|v| !v.is_empty())
.unwrap_or_else(|| version_id.clone());
let created = serde_json::from_str::<serde_json::Value>(supplied_lines[0])
.ok()
.and_then(|v| {
v.get("versionTime")
.and_then(|t| t.as_str())
.map(String::from)
});
Ok(Accepted {
did: did.to_string(),
version_id,
previous_version_id,
entries_added: supplied_lines.len() - served_lines.len(),
entry_count: supplied_lines.len(),
created,
})
}
static INSTALL_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
pub async fn install(did: &str, path: &Path, supplied: &str) -> Result<Accepted, InstallError> {
let _guard = INSTALL_LOCK.lock().await;
let served = match tokio::fs::read_to_string(path).await {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(
InstallRefusal::NotServedHere(format!("no log at {}", path.display())).into(),
);
}
Err(e) => return Err(InstallError::Io(e)),
};
let accepted = check(did, &served, supplied)?;
if accepted.entries_added == 0 {
return Ok(accepted);
}
let mut body = supplied.to_string();
if !body.ends_with('\n') {
body.push('\n');
}
let tmp = temp_path(path);
tokio::fs::write(&tmp, body.as_bytes())
.await
.map_err(InstallError::Io)?;
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(InstallError::Io(e));
}
Ok(accepted)
}
fn temp_path(path: &Path) -> PathBuf {
let mut name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(".installing");
path.with_file_name(name)
}
#[derive(Debug)]
pub enum InstallError {
Refused(InstallRefusal),
Io(std::io::Error),
}
impl From<InstallRefusal> for InstallError {
fn from(r: InstallRefusal) -> Self {
Self::Refused(r)
}
}
impl std::fmt::Display for InstallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Refused(r) => r.fmt(f),
Self::Io(e) => write!(f, "could not replace the served log: {e}"),
}
}
}
impl std::error::Error for InstallError {}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use didwebvh_rs::DIDWebVHState;
use didwebvh_rs::prelude::*;
use serde_json::json;
use super::*;
async fn mint(host: &str, steps: &[&str]) -> (String, Secret, Vec<String>) {
let (_, key) = didwebvh_rs::did_key::generate_did_key(KeyType::Ed25519).unwrap();
let pk = key.get_public_keymultibase().unwrap();
let document = json!({
"id": "{DID}",
"@context": ["https://www.w3.org/ns/did/v1"],
"verificationMethod": [{
"id": "{DID}#key-0", "type": "Multikey", "controller": "{DID}",
"publicKeyMultibase": pk,
}],
"authentication": ["{DID}#key-0"],
"assertionMethod": ["{DID}#key-0"],
});
let parameters = Parameters {
update_keys: Some(Arc::new(vec![Multibase::new(pk)])),
..Default::default()
};
let then = chrono::Utc::now() - chrono::Duration::minutes(10);
let config = CreateDIDConfig::builder()
.address(format!("https://{host}/"))
.authorization_key(key.clone())
.did_document(document)
.parameters(parameters)
.version_time(then.fixed_offset())
.build()
.unwrap();
let created = create_did(config).await.unwrap();
let did = created.did().to_string();
let mut state = DIDWebVHState::from_log_entries(vec![created.log_entry().clone()]);
state.validate().unwrap().assert_complete().unwrap();
for step in steps {
let mut doc = state.log_entries().last().unwrap().get_state().clone();
doc["service"] = json!([{ "id": format!("{did}#{step}"), "type": "TSPTransport",
"serviceEndpoint": "did:example:mediator" }]);
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
state.update_document(doc, &key).await.unwrap();
}
let lines = state
.log_entries()
.iter()
.map(|e| serde_json::to_string(&e.log_entry).unwrap())
.collect();
(did, key, lines)
}
fn log(lines: &[String]) -> String {
format!("{}\n", lines.join("\n"))
}
#[tokio::test]
async fn an_extension_signed_by_the_key_holder_is_accepted() {
let (did, _, lines) = mint("community.example", &["tsp"]).await;
let accepted = check(&did, &log(&lines[..1]), &log(&lines)).unwrap();
assert_eq!(accepted.entries_added, 1);
assert_eq!(accepted.did, did);
assert!(
accepted.version_id.starts_with("2-"),
"{}",
accepted.version_id
);
assert!(accepted.previous_version_id.starts_with("1-"));
}
#[tokio::test]
async fn the_served_log_again_adds_nothing() {
let (did, _, lines) = mint("community.example", &["tsp"]).await;
let accepted = check(&did, &log(&lines), &log(&lines)).unwrap();
assert_eq!(accepted.entries_added, 0);
assert_eq!(accepted.version_id, accepted.previous_version_id);
}
#[tokio::test]
async fn a_shorter_log_is_refused() {
let (did, _, lines) = mint("community.example", &["tsp"]).await;
let err = check(&did, &log(&lines), &log(&lines[..1])).unwrap_err();
assert!(matches!(err, InstallRefusal::NotAnExtension(_)), "{err}");
}
#[tokio::test]
async fn a_fork_is_refused() {
let (did, key, lines) = mint("community.example", &["tsp"]).await;
let mut state = DIDWebVHState::from_log_entries(vec![
LogEntry::deserialize_string(&lines[0], None).unwrap(),
]);
state.validate().unwrap().assert_complete().unwrap();
let mut doc = state.log_entries().last().unwrap().get_state().clone();
doc["service"] = json!([]);
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
state.update_document(doc, &key).await.unwrap();
let fork: Vec<String> = state
.log_entries()
.iter()
.map(|e| serde_json::to_string(&e.log_entry).unwrap())
.collect();
let err = check(&did, &log(&lines), &log(&fork)).unwrap_err();
assert!(matches!(err, InstallRefusal::NotAnExtension(_)), "{err}");
}
#[tokio::test]
async fn a_tampered_entry_is_refused() {
let (did, _, mut lines) = mint("community.example", &["tsp"]).await;
let served = log(&lines[..1]);
lines[1] = lines[1].replace("did:example:mediator", "did:example:attacker");
let err = check(&did, &served, &log(&lines)).unwrap_err();
assert!(matches!(err, InstallRefusal::InvalidLog(_)), "{err}");
}
#[tokio::test]
async fn another_dids_log_is_refused() {
let (did, _, lines) = mint("community.example", &[]).await;
let (_, _, other) = mint("elsewhere.example", &[]).await;
let err = check(&did, &log(&lines), &log(&other)).unwrap_err();
assert!(matches!(err, InstallRefusal::WrongDid { .. }), "{err}");
}
#[tokio::test]
async fn garbage_is_refused_as_invalid() {
let (did, _, lines) = mint("community.example", &[]).await;
for bad in ["", "not json\n", "{}\n"] {
let err = check(&did, &log(&lines), bad).unwrap_err();
assert!(
matches!(err, InstallRefusal::InvalidLog(_)),
"{bad:?}: {err}"
);
}
}
#[tokio::test]
async fn install_swaps_the_served_file() {
let (did, _, lines) = mint("community.example", &["tsp"]).await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("community.example.jsonl");
std::fs::write(&path, log(&lines[..1])).unwrap();
let accepted = install(&did, &path, &log(&lines)).await.unwrap();
assert_eq!(accepted.entries_added, 1);
assert_eq!(std::fs::read_to_string(&path).unwrap(), log(&lines));
assert!(!temp_path(&path).exists(), "no temporary file left behind");
let before = std::fs::read_to_string(&path).unwrap();
assert!(install(&did, &path, &log(&lines[..1])).await.is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
}
#[tokio::test]
async fn no_served_log_is_not_served_here() {
let dir = tempfile::tempdir().unwrap();
let err = install("did:webvh:x:y", &dir.path().join("none.jsonl"), "{}\n")
.await
.unwrap_err();
assert!(
matches!(err, InstallError::Refused(InstallRefusal::NotServedHere(_))),
"{err}"
);
}
}