1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
//! `onomancer name`: resolve a full onomancy name — anchor, then the
//! greedy walk across held documents.
//!
//! Held documents come from a directory of `<doc-anchor>.automerge`
//! files (filename = anchor: raw Automerge saves carry no intrinsic
//! ID until the substrate integration lands). A dev-tool bridge, not
//! the sync story.
use std::{cell::Cell, net::SocketAddr, path::PathBuf};
use clap::Args;
use onomancy_automerge::namestore::{DocumentNamestore, HeldDocuments};
use onomancy_core::{anchor::doc::DocAnchor, delegation_chain::DelegationChain};
use onomancy_dnssec::{
supported_name::{ParseSupportedNameError, SupportedName},
validator::{Validator, WalkError},
};
use onomancy_hickory::provider::FetchChainError;
use onomancy_keyhive::authority::KeyhiveAuthority;
use onomancy_protocol::resolve::{
namestore::{Authority, Replicas, Vouched},
resolution::{PartialReason, Resolution},
resolve,
};
use crate::say;
/// Resolve a full onomancy name (`~/…`, `@host/…`, `automerge:…/…`)
/// through a directory of held documents.
#[derive(Debug, Args)]
pub(crate) struct NameWalk {
/// The name to resolve.
name: String,
/// Directory of held documents (`<doc-anchor>.automerge` files).
#[arg(long)]
docs: PathBuf,
/// Your own root document's anchor — required for `~` names.
#[arg(long)]
root: Option<String>,
/// Recursive resolver (default: system resolvers, then 1.1.1.1).
#[arg(long)]
resolver: Option<SocketAddr>,
}
impl NameWalk {
/// Anchor, walk, report.
///
/// # Errors
///
/// Returns [`NameError`] for unparsable inputs, anchor-resolution
/// failures, and IO failures. A partial WALK is a report, not an
/// error (the designed norm under partition).
pub(crate) fn run(&self) -> Result<(), NameError> {
let name = SupportedName::parse(&self.name)?;
let root_anchor = self.anchor_of(&name)?;
say(&format!("anchor: {root_anchor}"));
let held = self.load_docs()?;
let root = held
.replica(&root_anchor)
.ok_or_else(|| NameError::RootNotHeld(Box::new(root_anchor)))?;
// Record each hop so the outcome names its documents.
let tracking = Tracking {
inner: &held,
last: Cell::new(Some(root_anchor)),
};
match resolve(root, name.segments(), &tracking) {
Resolution::Resolved { authority, .. } => {
let target = tracking
.last
.get()
.map_or_else(|| "(untracked)".into(), |anchor| anchor.to_string());
say(&format!("resolved \u{2713} \u{2192} automerge:{target}"));
say(&format!(
"authority: {} \u{26a0} {}",
authority.label(),
match authority {
Authority::TrustedSubstrate => "nothing checked \u{2014} dev bridge",
Authority::CarriageVerified =>
"delegation graph verified; content authorship not yet checkable",
}
));
}
Resolution::Partial { consumed, reason } => {
let why = match reason {
PartialReason::DanglingSegment => "no edge matches the next segment".into(),
PartialReason::UnsyncedTarget { target } => {
format!("next document not held: automerge:{target} (sync it, retry)")
}
};
say(&format!(
"partial: {consumed}/{} segments consumed \u{2014} {why}",
name.segments().len(),
));
}
}
Ok(())
}
/// The root document anchor for the name's trust anchor.
fn anchor_of(&self, name: &SupportedName) -> Result<DocAnchor, NameError> {
match name {
SupportedName::Doc(doc_name) => Ok(*doc_name.anchor()),
SupportedName::Local(_) => match &self.root {
Some(raw) => Ok(DocAnchor::parse(raw)?),
None => Err(NameError::LocalNeedsRoot),
},
SupportedName::Dns(dns_name) => {
let hostname = dns_name.anchor();
// Live: the zone's word for this hostname's document.
let provider = crate::provider(self.resolver);
let chain = crate::block_on(provider.fetch_chain(hostname))??;
let proof = Validator::iana().validate_detailed(hostname, &chain)?;
// The zone's word: the highest-serial record — and
// the maximum must be UNIQUE (dns-anchor, Comparing
// Records Offline). A serial tie across documents is
// zone equivocation, and RRset enumeration order
// must never resolve it: `max_by_key` returns the
// LAST maximum, which is exactly a fold on arrival
// order. Same rule, same wording as the wasm anchor
// path (`onomancy_wasm::held`).
let best = proof
.records
.iter()
.max_by_key(|record| record.serial())
.ok_or(NameError::NoBinding)?;
if proof.records.iter().any(|record| {
record.serial() == best.serial() && record.document() != best.document()
}) {
return Err(NameError::Equivocation);
}
Ok(*best.document())
}
}
}
/// Every `<doc-anchor>.automerge` in the docs directory, graded
/// by its sibling `<doc-anchor>.carriage` when one exists.
///
/// Fail-closed: a carriage that is present but does not vouch its
/// anchor REFUSES the document — broken evidence is worse than
/// none.
fn load_docs(&self) -> Result<HeldDocuments, NameError> {
let mut held = HeldDocuments::default();
for entry in std::fs::read_dir(&self.docs)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("automerge") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let anchor = DocAnchor::parse(stem).map_err(|source| NameError::BadDocFilename {
path: path.clone(),
source,
})?;
let doc = automerge::Automerge::load(&std::fs::read(&path)?)
.map_err(|_| NameError::UnloadableDoc(path.clone()))?;
let carriage_path = path.with_extension("carriage");
let authority = if carriage_path.exists() {
let carriage = DelegationChain::read_framed(&std::fs::read(&carriage_path)?)
.map_err(|_| NameError::UnloadableCarriage(carriage_path.clone()))?;
if !KeyhiveAuthority.vouches_document(&anchor, &carriage) {
return Err(NameError::CarriageRefused(carriage_path));
}
Authority::CarriageVerified
} else {
Authority::TrustedSubstrate
};
held = held.with_vouched(anchor, doc, authority);
}
Ok(held)
}
}
/// [`Replicas`] that remembers the last document fetched, so the
/// outcome can name where the walk landed.
struct Tracking<'a> {
inner: &'a HeldDocuments,
last: Cell<Option<DocAnchor>>,
}
impl Replicas for Tracking<'_> {
type Namestore = DocumentNamestore;
fn replica(&self, target: &DocAnchor) -> Option<Vouched<Self::Namestore>> {
let replica = self.inner.replica(target);
if replica.is_some() {
self.last.set(Some(*target));
}
replica
}
}
/// The name verb failed.
#[derive(Debug, thiserror::Error)]
pub(crate) enum NameError {
/// A docs-dir filename was not a document anchor.
#[error("doc file {path}: {source}")]
BadDocFilename {
/// The offending file.
path: PathBuf,
/// Why its stem failed to parse.
source: onomancy_core::anchor::doc::ParseDocAnchorError,
},
/// The live chain could not be fetched.
#[error(transparent)]
Fetch(#[from] FetchChainError),
/// File or runtime IO failed.
#[error(transparent)]
Io(#[from] std::io::Error),
/// A `~` name arrived without `--root`.
#[error("local (~) names resolve from YOUR root document: pass --root <doc-anchor>")]
LocalNeedsRoot,
/// The name did not parse.
#[error("name: {0}")]
Name(#[from] ParseSupportedNameError),
/// Two documents share the zone's highest serial.
#[error(
"the zone equivocates: two documents share the highest serial — \
refusing to let RRset order decide"
)]
Equivocation,
/// The zone attests no binding record.
#[error("the zone attests no binding for this hostname")]
NoBinding,
/// The `--root` argument was not a document anchor.
#[error("root anchor: {0}")]
Root(#[from] onomancy_core::anchor::doc::ParseDocAnchorError),
/// The root document is not in the docs directory.
#[error("root document not held: add {0}.automerge to --docs")]
RootNotHeld(Box<DocAnchor>),
/// A carriage file was present but did not vouch its document —
/// refused rather than downgraded.
#[error("carriage does not vouch its document (refusing the doc): {0}")]
CarriageRefused(PathBuf),
/// A carriage file did not parse.
#[error("not a framed carriage: {0}")]
UnloadableCarriage(PathBuf),
/// A doc file did not load as Automerge.
#[error("not an automerge document: {0}")]
UnloadableDoc(PathBuf),
/// The live chain failed DNSSEC validation.
#[error("live chain invalid: {0}")]
Walk(#[from] WalkError),
}