This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-migrate / src / emit.rs
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}