This repository has no description
17 kB
533 lines
1use std::path::{Path, PathBuf};
2
3use knot_cob::{Checkpoint, CobError, CobHome, CobStore, Evaluate};
4use knot_cobs::{
5 CollaboratorsChange, CollaboratorsCob, Grant, MembersChange, MembersCob, Registration,
6 RegistryChange, RegistryError, RepoRegistryCob,
7};
8use knot_git::{GitError, Layout};
9use knot_runtime::Signer;
10use knot_types::{AccountDid, ActorId, KnotHostname, KnotId, ObjectFormat, RepoDid, RepoName};
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use url::Url;
14
15use crate::mapping::{AdoptRepo, MappedGrant, Mapping};
16
17#[derive(Debug, thiserror::Error)]
18pub enum EmitError {
19 #[error("meta-repo bootstrap: {0}")]
20 Meta(#[from] GitError),
21 #[error("open adopted repo {repo}: {source}")]
22 OpenRepo { repo: RepoDid, source: GitError },
23 #[error("registry record for {repo} has name {existing:?} and the mapping has {name:?}")]
24 RegistryNameChanged {
25 repo: RepoDid,
26 existing: RepoName,
27 name: RepoName,
28 },
29 #[error("{cob} write failed: {source}")]
30 Cob { cob: &'static str, source: CobError },
31 #[error("registry write failed: {0}")]
32 Registry(#[from] RegistryError),
33 #[error("{cob} is split across {count} objects")]
34 SplitObject { cob: &'static str, count: usize },
35 #[error("write {path}: {source}")]
36 Io {
37 path: PathBuf,
38 source: std::io::Error,
39 },
40 #[error("host key {path} doesn't parse as an OpenSSH private key: {source}")]
41 HostKey {
42 path: PathBuf,
43 source: ssh_key::Error,
44 },
45 #[error("knot cannot load the passphrase-protected host key {path} unattended")]
46 EncryptedHostKey { path: PathBuf },
47 #[error("config template has no line for {section}.{key}")]
48 TemplateDrift {
49 section: &'static str,
50 key: &'static str,
51 },
52 #[error("{cob} is missing {missing} entries after append")]
53 Incomplete { cob: &'static str, missing: usize },
54}
55
56#[derive(Debug, Default, PartialEq, Eq)]
57pub struct GrantSetOutcome {
58 pub appended: u64,
59 pub already_present: u64,
60}
61
62pub struct CobSummary {
63 pub members: GrantSetOutcome,
64 pub registrations: GrantSetOutcome,
65 pub collaborators: GrantSetOutcome,
66}
67
68pub fn write_cobs(
69 layout: &Layout,
70 knot: &KnotId,
71 mapping: &Mapping,
72 signer: &dyn Signer,
73) -> Result<CobSummary, EmitError> {
74 let meta = layout.bootstrap_meta(knot)?;
75 let home = CobHome::from(knot);
76 let store = CobStore::new(&meta);
77
78 let members = write_grant_set::<MembersCob>(
79 &store,
80 &home,
81 "members",
82 &mapping.members,
83 signer,
84 MembersChange::Add,
85 )?;
86 let registrations = write_registry(&store, &home, &mapping.repos, signer)?;
87 let collaborators =
88 mapping
89 .repos
90 .iter()
91 .try_fold(GrantSetOutcome::default(), |outcome, repo| {
92 let sum = write_repo_collaborators(layout, repo, signer)?;
93 Ok::<_, EmitError>(GrantSetOutcome {
94 appended: outcome.appended + sum.appended,
95 already_present: outcome.already_present + sum.already_present,
96 })
97 })?;
98
99 Ok(CobSummary {
100 members,
101 registrations,
102 collaborators,
103 })
104}
105
106fn write_repo_collaborators(
107 layout: &Layout,
108 repo: &AdoptRepo,
109 signer: &dyn Signer,
110) -> Result<GrantSetOutcome, EmitError> {
111 if repo.collaborators.is_empty() {
112 return Ok(GrantSetOutcome::default());
113 }
114 let git = layout
115 .open(&repo.did)
116 .map_err(|source| EmitError::OpenRepo {
117 repo: repo.did.clone(),
118 source,
119 })?;
120 let home = CobHome::from(&repo.did);
121 let store = CobStore::new(&git);
122 write_grant_set::<CollaboratorsCob>(
123 &store,
124 &home,
125 "collaborators",
126 &repo.collaborators,
127 signer,
128 CollaboratorsChange::Add,
129 )
130}
131
132fn write_grant_set<E>(
133 store: &CobStore,
134 home: &CobHome,
135 cob: &'static str,
136 grants: &[MappedGrant],
137 signer: &dyn Signer,
138 make: impl Fn(Grant) -> E::Change,
139) -> Result<GrantSetOutcome, EmitError>
140where
141 E: Checkpoint + Evaluate<State = knot_cobs::Roster>,
142 E::State: Serialize + DeserializeOwned,
143{
144 write_batch::<E, MappedGrant>(
145 store,
146 home,
147 cob,
148 grants,
149 signer,
150 |grant| make(to_grant(grant)),
151 |grant| grant.created_at,
152 |roster, grant| roster.contains(&grant.subject),
153 |_| Ok(()),
154 )
155}
156
157fn write_registry(
158 store: &CobStore,
159 home: &CobHome,
160 repos: &[AdoptRepo],
161 signer: &dyn Signer,
162) -> Result<GrantSetOutcome, EmitError> {
163 write_batch::<RepoRegistryCob, AdoptRepo>(
164 store,
165 home,
166 "registry",
167 repos,
168 signer,
169 |repo| RegistryChange::Register(registration(repo)),
170 |repo| repo.created_at,
171 |registry, repo| {
172 registry.record_of(&repo.did).is_some_and(|record| {
173 record.owner == repo.owner && record.rkey == repo.rkey && record.name == repo.name
174 })
175 },
176 |registry| {
177 repos.iter().try_for_each(|repo| {
178 match (
179 registry.record_of(&repo.did),
180 registry.resolve(&repo.owner, &repo.rkey),
181 ) {
182 (Some(record), _) if record.owner != repo.owner || record.rkey != repo.rkey => {
183 Err(EmitError::Registry(RegistryError::AlreadyRegistered {
184 repo: repo.did.clone(),
185 owner: record.owner.clone(),
186 rkey: record.rkey.clone(),
187 }))
188 }
189 (Some(record), _) if record.name != repo.name => {
190 Err(EmitError::RegistryNameChanged {
191 repo: repo.did.clone(),
192 existing: record.name.clone(),
193 name: repo.name.clone(),
194 })
195 }
196 (None, Some(holder)) if holder != &repo.did => {
197 Err(EmitError::Registry(RegistryError::RkeyTaken {
198 owner: repo.owner.clone(),
199 rkey: repo.rkey.clone(),
200 existing: holder.clone(),
201 }))
202 }
203 _ => Ok(()),
204 }
205 })
206 },
207 )
208}
209
210#[allow(clippy::too_many_arguments)]
211fn write_batch<E, T>(
212 store: &CobStore,
213 home: &CobHome,
214 cob: &'static str,
215 items: &[T],
216 signer: &dyn Signer,
217 make: impl Fn(&T) -> E::Change,
218 stamp: impl Fn(&T) -> knot_types::UnixSeconds,
219 present: impl Fn(&E::State, &T) -> bool,
220 precheck: impl Fn(&E::State) -> Result<(), EmitError>,
221) -> Result<GrantSetOutcome, EmitError>
222where
223 E: Checkpoint,
224 E::State: Serialize + DeserializeOwned,
225{
226 let fail = |source: CobError| EmitError::Cob { cob, source };
227 let objects = store.list::<E>().map_err(fail)?;
228 let (object, state, created) = match (objects.as_slice(), items) {
229 (_, []) => return Ok(GrantSetOutcome::default()),
230 ([], [first, ..]) => {
231 let change = make(first);
232 let created = store
233 .create(home, &change, signer, stamp(first))
234 .map_err(fail)?;
235 let author = ActorId::from_secp256k1(signer.public_key().as_bytes());
236 (created.object, E::apply(E::initial(), change, &author), 1)
237 }
238 ([object], _) => {
239 let (state, _) = store.materialize::<E>(*object).map_err(fail)?;
240 (*object, state, 0)
241 }
242 (many, _) => {
243 return Err(EmitError::SplitObject {
244 cob,
245 count: many.len(),
246 });
247 }
248 };
249 precheck(&state)?;
250
251 let missing: Vec<&T> = items.iter().filter(|item| !present(&state, item)).collect();
252 missing.split_last().map_or(Ok(()), |(last, head)| {
253 let changes: Vec<E::Change> = head.iter().map(|item| make(item)).collect();
254 store
255 .extend(
256 home,
257 object,
258 changes.iter().zip(head.iter().map(|item| stamp(item))),
259 signer,
260 )
261 .map_err(fail)?;
262 store
263 .update_with_checkpointed::<E, CobError>(home, object, signer, stamp(last), |_| {
264 Ok(make(last))
265 })
266 .map(|_| ())
267 .map_err(fail)
268 })?;
269
270 let (folded, _) = store.materialize::<E>(object).map_err(fail)?;
271 let absent = items.iter().filter(|item| !present(&folded, item)).count();
272 match absent {
273 0 => Ok(GrantSetOutcome {
274 appended: created + missing.len() as u64,
275 already_present: (items.len() as u64)
276 .saturating_sub(missing.len() as u64)
277 .saturating_sub(created),
278 }),
279 count => Err(EmitError::Incomplete {
280 cob,
281 missing: count,
282 }),
283 }
284}
285
286fn registration(repo: &AdoptRepo) -> Registration {
287 Registration {
288 owner: repo.owner.clone(),
289 rkey: repo.rkey.clone(),
290 name: repo.name.clone(),
291 repo: repo.did.clone(),
292 created_at: repo.created_at,
293 }
294}
295
296fn to_grant(grant: &MappedGrant) -> Grant {
297 Grant {
298 subject: grant.subject.clone(),
299 added_by: grant.added_by.clone(),
300 created_at: grant.created_at,
301 }
302}
303
304#[derive(Serialize)]
305struct ArchivedKey<'a> {
306 repo_did: &'a RepoDid,
307 key_type: &'a str,
308 #[serde(serialize_with = "secret_str")]
309 secret_key_hex: zeroize::Zeroizing<String>,
310}
311
312fn secret_str<S: serde::Serializer>(
313 value: &zeroize::Zeroizing<String>,
314 serializer: S,
315) -> Result<S::Ok, S::Error> {
316 serializer.serialize_str(value)
317}
318
319// do not be alarmed, for there is a plan for this
320pub fn write_key_archive(path: &Path, repos: &[AdoptRepo]) -> Result<(), EmitError> {
321 let keys: Vec<ArchivedKey<'_>> = repos
322 .iter()
323 .map(|repo| ArchivedKey {
324 repo_did: &repo.did,
325 key_type: "k256",
326 secret_key_hex: repo.signing_key.to_hex(),
327 })
328 .collect();
329 let body = zeroize::Zeroizing::new(
330 serde_json::to_string_pretty(&keys).expect("key archive serializes"),
331 );
332 write_private(path, body.as_bytes())
333}
334
335pub struct HostKey {
336 bytes: zeroize::Zeroizing<Vec<u8>>,
337 pub algorithm: ssh_key::Algorithm,
338}
339
340impl HostKey {
341 pub fn write_to(&self, destination: &Path) -> Result<(), EmitError> {
342 write_private(destination, &self.bytes)
343 }
344}
345
346pub fn load_host_key(source: &Path) -> Result<HostKey, EmitError> {
347 let bytes = zeroize::Zeroizing::new(std::fs::read(source).map_err(|error| EmitError::Io {
348 path: source.to_path_buf(),
349 source: error,
350 })?);
351 let key = ssh_key::PrivateKey::from_openssh(bytes.as_slice()).map_err(|error| {
352 EmitError::HostKey {
353 path: source.to_path_buf(),
354 source: error,
355 }
356 })?;
357 if key.is_encrypted() {
358 return Err(EmitError::EncryptedHostKey {
359 path: source.to_path_buf(),
360 });
361 }
362 Ok(HostKey {
363 algorithm: key.algorithm(),
364 bytes,
365 })
366}
367
368fn write_private(path: &Path, bytes: &[u8]) -> Result<(), EmitError> {
369 use std::io::Write;
370 let io = |error: std::io::Error| EmitError::Io {
371 path: path.to_path_buf(),
372 source: error,
373 };
374 let mut options = std::fs::OpenOptions::new();
375 options.write(true).create(true).truncate(true);
376 #[cfg(unix)]
377 {
378 use std::os::unix::fs::OpenOptionsExt;
379 options.mode(0o600);
380 }
381 let mut file = options.open(path).map_err(io)?;
382 #[cfg(unix)]
383 {
384 use std::os::unix::fs::PermissionsExt;
385 file.set_permissions(std::fs::Permissions::from_mode(0o600))
386 .map_err(io)?;
387 }
388 file.write_all(bytes).map_err(io)?;
389 file.sync_all().map_err(io)?;
390 Ok(())
391}
392
393#[derive(Debug, Clone, PartialEq, Eq)]
394// This name goes into the generated config as `secrets.master_key_env`,
395// where knot-config runs `is_env_var_name`.
396// So same rule here such the migration fails *now* instead of at the end.
397pub struct MasterKeyEnv(String);
398
399impl MasterKeyEnv {
400 pub fn new(value: impl Into<String>) -> Result<Self, String> {
401 let value = value.into();
402 let valid = !value.is_empty()
403 && !value.starts_with(|c: char| c.is_ascii_digit())
404 && value
405 .chars()
406 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
407 match valid {
408 true => Ok(Self(value)),
409 false => Err(value),
410 }
411 }
412
413 pub fn as_str(&self) -> &str {
414 &self.0
415 }
416}
417
418impl std::fmt::Display for MasterKeyEnv {
419 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420 f.write_str(&self.0)
421 }
422}
423
424pub struct ConfigValues {
425 pub hostname: KnotHostname,
426 pub admins: Vec<AccountDid>,
427 pub scan_path: PathBuf,
428 pub ssh_host_key_file: PathBuf,
429 pub sealed_key_file: PathBuf,
430 pub master_key_env: MasterKeyEnv,
431 pub object_format: ObjectFormat,
432 pub plc_directory: Url,
433}
434
435pub fn render_config(values: &ConfigValues) -> Result<String, EmitError> {
436 let fills: Vec<((&'static str, &'static str), String)> = [
437 (("server", "hostname"), quote(values.hostname.as_str())),
438 (
439 ("server", "admins"),
440 format!(
441 "[{}]",
442 values
443 .admins
444 .iter()
445 .map(|admin| quote(admin.as_str()))
446 .collect::<Vec<_>>()
447 .join(", ")
448 ),
449 ),
450 (
451 ("server", "ssh_host_key_file"),
452 quote_path(&values.ssh_host_key_file),
453 ),
454 (("acl", "admission"), quote("closed")),
455 (("repo", "scan_path"), quote_path(&values.scan_path)),
456 (
457 ("git", "object_format"),
458 quote(values.object_format.capability()),
459 ),
460 (
461 ("secrets", "sealed_key_file"),
462 quote_path(&values.sealed_key_file),
463 ),
464 (
465 ("secrets", "master_key_env"),
466 quote(values.master_key_env.as_str()),
467 ),
468 (
469 ("atproto", "plc_directory"),
470 quote(values.plc_directory.as_str()),
471 ),
472 ]
473 .into_iter()
474 .collect();
475
476 let template = knot_config::template();
477 let (lines, pending, _) = template.lines().fold(
478 (Vec::new(), fills, ""),
479 |(mut lines, pending, section), line| {
480 let section = line
481 .trim()
482 .strip_prefix('[')
483 .and_then(|rest| rest.strip_suffix(']'))
484 .unwrap_or(section);
485 let matched = pending.iter().position(|((expected, key), _)| {
486 *expected == section && line.trim().starts_with(&format!("#{key} ="))
487 });
488 let remaining = match matched {
489 Some(index) => {
490 let ((_, key), value) = &pending[index];
491 lines.push(format!("{key} = {value}"));
492 pending
493 .into_iter()
494 .enumerate()
495 .filter(|(position, _)| *position != index)
496 .map(|(_, fill)| fill)
497 .collect()
498 }
499 None => {
500 lines.push(line.to_string());
501 pending
502 }
503 };
504 (lines, remaining, section)
505 },
506 );
507 pending.first().map_or(Ok(()), |((section, key), _)| {
508 Err(EmitError::TemplateDrift { section, key })
509 })?;
510 Ok(lines.join("\n") + "\n")
511}
512
513fn quote(value: &str) -> String {
514 let escaped: String = value
515 .chars()
516 .map(|c| match c {
517 '"' => "\\\"".to_string(),
518 '\\' => "\\\\".to_string(),
519 '\u{8}' => "\\b".to_string(),
520 '\t' => "\\t".to_string(),
521 '\n' => "\\n".to_string(),
522 '\u{c}' => "\\f".to_string(),
523 '\r' => "\\r".to_string(),
524 c if c.is_control() => format!("\\u{:04X}", u32::from(c)),
525 c => c.to_string(),
526 })
527 .collect();
528 format!("\"{escaped}\"")
529}
530
531fn quote_path(path: &Path) -> String {
532 quote(&path.to_string_lossy())
533}