1use std::path::{Path, PathBuf};
20
21use super::archive;
22use super::protocol::{
23 Config, ConfigTrust, IndexLine, KeyEntry, SigningKey, delegation_payload, fingerprint,
24 revocation_payload, rotation_payload, verify,
25};
26
27pub fn init(dir: &Path, id: &str, key_out: &Path) -> Result<String, String> {
31 if bynk_project::manifest::registry_name_problem(id).is_some() {
32 return Err(format!("`{id}` isn't a registry id: use `[a-z][a-z0-9-]*`"));
33 }
34 let config_path = dir.join("config.json");
35 if config_path.exists() {
36 return Err(format!("`{}` already exists", config_path.display()));
37 }
38 let abs = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
39 if abs(key_out).starts_with(abs(dir)) {
40 return Err(format!(
41 "the signing key `{}` would be inside the registry directory, which is published — write it elsewhere with `--key-out`",
42 key_out.display()
43 ));
44 }
45 if key_out.exists() {
46 return Err(format!("`{}` already exists", key_out.display()));
47 }
48 let (key, pkcs8) = SigningKey::generate()?;
49 write_private(key_out, &pkcs8)?;
50 let config = Config {
51 id: id.to_string(),
52 dl: "dl".to_string(),
53 api: None,
54 token_auth: false,
55 keys: vec![KeyEntry {
56 key: key.public(),
57 sig: None,
58 }],
59 delegations: vec![],
60 revoked: vec![],
61 };
62 std::fs::create_dir_all(dir)
63 .map_err(|e| format!("could not create `{}`: {e}", dir.display()))?;
64 std::fs::write(&config_path, config.render())
65 .map_err(|e| format!("could not write `{}`: {e}", config_path.display()))?;
66 Ok(format!("key = \"{}\"", key.public()))
67}
68
69pub fn add(dir: &Path, package_dir: &Path, key_file: &Path) -> Result<String, String> {
73 let config = read_config(dir)?;
74 let key = signing_key(key_file)?;
75 current_key(&config, &key, key_file)?;
76 let (bytes, _) =
77 archive::pack(package_dir).map_err(|e| format!("could not pack the package: {e}"))?;
78 let admitted = admit(dir, &config, &key, &bytes, None).map_err(|r| r.message)?;
79 let line = &admitted.line;
80 Ok(if admitted.fresh {
81 format!("{} {}", line.name, line.version)
82 } else {
83 format!(
84 "{} {} — already present, identical, so nothing changed",
85 line.name, line.version
86 )
87 })
88}
89
90pub(crate) use bynk_registry_core::Refusal;
91
92#[derive(Debug, Clone, PartialEq, Eq)]
95pub(crate) struct Admitted {
96 pub line: IndexLine,
97 pub fresh: bool,
98}
99
100fn refuse(code: &'static str, message: impl Into<String>) -> Refusal {
101 Refusal {
102 code,
103 message: message.into(),
104 }
105}
106
107pub(crate) fn admit(
121 dir: &Path,
122 config: &Config,
123 key: &SigningKey,
124 upload: &[u8],
125 expected: Option<(&str, &str)>,
126) -> Result<Admitted, Refusal> {
127 if config.dl.contains("://") {
128 return Err(refuse(
129 "internal",
130 format!(
131 "registry `{}` keeps its archives at `{}`, which isn't a directory this registry can write",
132 config.id, config.dl
133 ),
134 ));
135 }
136 admit_checked(dir, config, key, upload, expected)
137}
138
139fn admit_checked(
140 dir: &Path,
141 config: &Config,
142 key: &SigningKey,
143 upload: &[u8],
144 expected: Option<(&str, &str)>,
145) -> Result<Admitted, Refusal> {
146 let core = bynk_registry_core::admit(upload, expected)?;
147 let (name, version, checksum) = (core.name, core.version, core.checksum);
148 let (org, package) = name
149 .split_once('/')
150 .expect("an admitted name is org/package");
151 let index_path = dir.join(org).join(package);
152 let existing = std::fs::read_to_string(&index_path).unwrap_or_default();
153 for raw in existing.lines().filter(|l| !l.trim().is_empty()) {
154 let line = IndexLine::parse(raw).map_err(|e| refuse("internal", e))?;
155 if line.version == version {
156 if line.withdrawn.is_some() {
159 return Err(refuse(
160 "withdrawn",
161 format!(
162 "`{name}` {version} was withdrawn from the registry and can't be published again; bump `version`"
163 ),
164 ));
165 }
166 if line.checksum == checksum {
167 return Ok(Admitted { line, fresh: false });
168 }
169 return Err(refuse(
170 "version_exists",
171 format!(
172 "`{name}` {version} is already in the registry with a different tree — its index is append-only; bump `version`"
173 ),
174 ));
175 }
176 }
177 let hex = bynk_project::lock::checksum_hex(&checksum).expect("a checksum pack wrote");
178 let archive_path = dir.join(&config.dl).join("sha256").join(&hex);
179 write_replacing(&archive_path, &core.archive)
180 .map_err(|e| refuse("internal", format!("could not write the archive: {e}")))?;
181 let mut line = IndexLine {
182 name,
183 version,
184 checksum,
185 deps: core.deps,
186 npm: core.npm,
187 toolchain: None,
188 yanked: false,
189 withdrawn: None,
190 sig: String::new(),
191 };
192 line.sig = key.sign(&line.payload(&config.id));
193 let mut out = existing;
194 if !out.is_empty() && !out.ends_with('\n') {
195 out.push('\n');
196 }
197 out.push_str(&line.render());
198 out.push('\n');
199 write_replacing(&index_path, out.as_bytes())
200 .map_err(|e| refuse("internal", format!("could not write the index: {e}")))?;
201 Ok(Admitted { line, fresh: true })
202}
203
204pub(crate) fn write_replacing(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
207 if let Some(parent) = path.parent() {
208 std::fs::create_dir_all(parent)?;
209 }
210 let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
211 std::fs::write(&tmp, bytes)?;
212 std::fs::rename(&tmp, path)
213}
214
215pub(crate) fn current_key(
217 config: &Config,
218 key: &SigningKey,
219 key_file: &Path,
220) -> Result<(), String> {
221 let public = key.public();
223 let signers = newest_signers(config)?;
224 if signers.revoked.contains(&public) {
225 return Err(format!(
226 "`{}` is a key registry `{}` revoked",
227 key_file.display(),
228 config.id
229 ));
230 }
231 let newest = &config.keys.last().expect("parse checks").key;
232 let delegate = signers.keys.contains(&public) && !config.keys.iter().any(|k| k.key == public);
233 if public != *newest && !delegate {
234 return Err(format!(
235 "`{}` isn't registry `{}`'s current key or a delegate of it",
236 key_file.display(),
237 config.id
238 ));
239 }
240 Ok(())
241}
242
243fn own_chain(config: &Config) -> Result<Vec<String>, String> {
246 let first = fingerprint(&config.keys[0].key).ok_or_else(|| {
247 format!(
248 "registry `{}`'s first key isn't an `ed25519:` key",
249 config.id
250 )
251 })?;
252 config
253 .chain_from(&first)
254 .ok_or_else(|| {
255 format!(
256 "registry `{}`'s key chain doesn't verify from its first key — a link in `config.json`'s `keys` isn't signed by the key before it; restore it from a backup, or start a new chain with `bynk registry init` and have clients re-pin",
257 config.id
258 )
259 })
260}
261
262fn newest_signers(config: &Config) -> Result<super::protocol::Signers, String> {
267 own_chain(config)?;
268 let newest = config.keys.last().expect("parse checks").key.clone();
269 Ok(config.signers(&[newest]))
270}
271
272fn chain_key(config: &Config, key_file: &Path) -> Result<SigningKey, String> {
277 let key = signing_key(key_file)?;
278 own_chain(config)?;
279 if key.public() != config.keys.last().expect("parse checks").key {
280 return Err(format!(
281 "`{}` isn't registry `{}`'s newest chain key — only it may delegate or revoke",
282 key_file.display(),
283 config.id
284 ));
285 }
286 Ok(key)
287}
288
289fn new_key(dir: &Path, out: &Path) -> Result<SigningKey, String> {
291 let abs = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf());
292 if abs(out).starts_with(abs(dir)) {
293 return Err(format!(
294 "the key `{}` would be inside the registry directory, which is published — write it elsewhere",
295 out.display()
296 ));
297 }
298 if out.exists() {
299 return Err(format!("`{}` already exists", out.display()));
300 }
301 let (key, pkcs8) = SigningKey::generate()?;
302 write_private(out, &pkcs8)?;
303 Ok(key)
304}
305
306fn write_config(dir: &Path, config: &Config) -> Result<(), String> {
307 let text = config.render();
309 if Config::parse(text.as_bytes()).as_ref() != Ok(config) {
310 return Err("the new `config.json` doesn't read back as written".to_string());
311 }
312 write_replacing(&dir.join("config.json"), text.as_bytes())
313 .map_err(|e| format!("could not write `config.json`: {e}"))
314}
315
316pub fn rotate(dir: &Path, key_file: &Path, key_out: &Path) -> Result<String, String> {
319 let mut config = read_config(dir)?;
320 let old = signing_key(key_file)?;
321 if old.public() != config.keys.last().expect("parse checks").key {
322 return Err(format!(
323 "`{}` isn't registry `{}`'s newest chain key, which is the only key that may extend the chain",
324 key_file.display(),
325 config.id
326 ));
327 }
328 let valid = newest_signers(&config)?;
329 let new = new_key(dir, key_out)?;
330 config.keys.push(KeyEntry {
331 key: new.public(),
332 sig: Some(old.sign(&rotation_payload(&config.id, &new.public()))),
333 });
334 let id = config.id.clone();
338 for d in &mut config.delegations {
339 if valid.keys.contains(&d.key) {
340 d.sig = Some(new.sign(&delegation_payload(&id, &d.key)));
341 }
342 }
343 for r in &mut config.revoked {
344 if valid.revoked.contains(&r.key) {
345 r.sig = Some(new.sign(&revocation_payload(&id, &r.key)));
346 }
347 }
348 let after = newest_signers(&config)?;
349 if after.keys[1..] != valid.keys[1..] || after.revoked != valid.revoked {
350 return Err("rotating would change which delegates are valid".to_string());
351 }
352 write_config(dir, &config)?;
353 Ok(new.public())
354}
355
356pub fn delegate(dir: &Path, key_file: &Path, key_out: &Path) -> Result<String, String> {
359 let mut config = read_config(dir)?;
360 let chain = chain_key(&config, key_file)?;
361 let new = new_key(dir, key_out)?;
362 config.delegations.push(KeyEntry {
363 key: new.public(),
364 sig: Some(chain.sign(&delegation_payload(&config.id, &new.public()))),
365 });
366 if !config
367 .signers(&[config.keys.last().expect("parse checks").key.clone()])
368 .keys
369 .contains(&new.public())
370 {
371 return Err("the delegation doesn't verify".to_string());
372 }
373 write_config(dir, &config)?;
374 Ok(new.public())
375}
376
377pub fn revoke(dir: &Path, key_file: &Path, target: &str, force: bool) -> Result<usize, String> {
381 let mut config = read_config(dir)?;
382 let chain = chain_key(&config, key_file)?;
383 if !config.delegations.iter().any(|d| d.key == target) {
384 return Err(format!(
385 "`{target}` isn't a delegate of registry `{}` — only delegates can be revoked",
386 config.id
387 ));
388 }
389 if config.revoked.iter().any(|r| r.key == target) {
390 return Err(format!("`{target}` is already revoked"));
391 }
392 config.revoked.push(KeyEntry {
393 key: target.to_string(),
394 sig: Some(chain.sign(&revocation_payload(&config.id, target))),
395 });
396 let signers = newest_signers(&config)?;
397 if !signers.revoked.iter().any(|k| k == target) {
398 return Err("the revocation doesn't verify".to_string());
399 }
400 let mut stranded = 0;
401 let mut by_target = 0;
402 for path in index_files(dir, &config)? {
403 for line in read_lines(&path)? {
404 let payload = line.payload(&config.id);
405 if verify(target, &payload, &line.sig) {
406 by_target += 1;
407 }
408 if !signers.keys.iter().any(|k| verify(k, &payload, &line.sig)) {
409 stranded += 1;
410 }
411 }
412 }
413 if stranded > 0 && !force {
414 return Err(format!(
415 "{stranded} index line(s) would have no valid signer — re-sign them with another key first (`bynk registry resign`), or pass `--force`"
416 ));
417 }
418 write_config(dir, &config)?;
419 Ok(by_target)
420}
421
422pub fn resign(dir: &Path, key_file: &Path) -> Result<usize, String> {
426 let config = read_config(dir)?;
427 let key = signing_key(key_file)?;
428 current_key(&config, &key, key_file)?;
429 let signers = newest_signers(&config)?;
437 let known: Vec<&String> = signers.keys.iter().chain(&signers.revoked).collect();
438 let mut files = Vec::new();
439 for path in index_files(dir, &config)? {
440 let lines = read_lines(&path)?;
441 files.push((path, lines));
442 }
443 let mut unknown = Vec::new();
444 for (_, lines) in &files {
445 for line in lines {
446 let payload = line.payload(&config.id);
447 if !known.iter().any(|k| verify(k, &payload, &line.sig)) {
448 unknown.push(format!("{} {}", line.name, line.version));
449 }
450 }
451 }
452 if !unknown.is_empty() {
453 return Err(format!(
454 "no key this registry knows signed {} — refusing to re-sign it: {}",
455 if unknown.len() == 1 {
456 "this line"
457 } else {
458 "these lines"
459 },
460 unknown.join(", ")
461 ));
462 }
463 let mut count = 0;
464 for (path, lines) in files {
465 let mut out = String::new();
466 for mut line in lines {
467 line.sig = key.sign(&line.payload(&config.id));
468 out.push_str(&line.render());
469 out.push('\n');
470 count += 1;
471 }
472 write_replacing(&path, out.as_bytes())
473 .map_err(|e| format!("could not write `{}`: {e}", path.display()))?;
474 }
475 Ok(count)
476}
477
478fn index_files(dir: &Path, config: &Config) -> Result<Vec<PathBuf>, String> {
480 let dl = std::path::absolute(dir.join(&config.dl)).unwrap_or_else(|_| dir.join(&config.dl));
481 let mut out = Vec::new();
482 let read = |p: &Path| {
483 std::fs::read_dir(p).map_err(|e| format!("could not read `{}`: {e}", p.display()))
484 };
485 for org in read(dir)?.flatten() {
486 let org = org.path();
487 let abs = std::path::absolute(&org).unwrap_or_else(|_| org.clone());
488 let hidden = org
489 .file_name()
490 .and_then(|n| n.to_str())
491 .is_some_and(|n| n.starts_with('.'));
492 if !org.is_dir() || hidden || abs.starts_with(&dl) {
493 continue;
494 }
495 for file in read(&org)?.flatten() {
496 let file = file.path();
497 let name = file
498 .file_name()
499 .and_then(|n| n.to_str())
500 .unwrap_or_default();
501 if file.is_file() && !name.starts_with('.') && !name.contains(".tmp.") {
502 out.push(file);
503 }
504 }
505 }
506 out.sort();
507 Ok(out)
508}
509
510fn read_lines(path: &Path) -> Result<Vec<IndexLine>, String> {
511 let text = std::fs::read_to_string(path)
512 .map_err(|e| format!("could not read `{}`: {e}", path.display()))?;
513 text.lines()
514 .filter(|l| !l.trim().is_empty())
515 .map(|l| IndexLine::parse(l).map_err(|e| format!("`{}`: {e}", path.display())))
516 .collect()
517}
518
519pub fn yank(dir: &Path, package: &str, version: &str, undo: bool) -> Result<(), String> {
521 yank_line(dir, package, version, undo).map_err(|r| r.message)
522}
523
524pub(crate) fn yank_line(
527 dir: &Path,
528 package: &str,
529 version: &str,
530 undo: bool,
531) -> Result<(), Refusal> {
532 let index_path = dir.join(package);
533 let text = std::fs::read_to_string(&index_path).map_err(|_| {
534 refuse(
535 "not_found",
536 format!("the registry has no package `{package}`"),
537 )
538 })?;
539 let mut found = false;
540 let mut out = String::new();
541 for raw in text.lines().filter(|l| !l.trim().is_empty()) {
542 let mut line = IndexLine::parse(raw).map_err(|e| refuse("internal", e))?;
543 if line.version == version {
544 if undo && line.withdrawn.is_some() {
547 return Err(refuse(
548 "withdrawn",
549 format!(
550 "`{package}` {version} was withdrawn, so it can't be un-yanked; a withdrawal is final"
551 ),
552 ));
553 }
554 line.yanked = !undo;
555 found = true;
556 }
557 out.push_str(&line.render());
558 out.push('\n');
559 }
560 if !found {
561 return Err(refuse(
562 "not_found",
563 format!("the registry has no `{package}` {version}"),
564 ));
565 }
566 write_replacing(&index_path, out.as_bytes())
567 .map_err(|e| refuse("internal", format!("could not write the index: {e}")))
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
572pub enum ArchiveOutcome {
573 Deleted,
575 Shared(String),
578 Missing,
580 Finished,
583}
584
585pub fn withdraw(
592 dir: &Path,
593 package: &str,
594 version: &str,
595 reason: &str,
596) -> Result<ArchiveOutcome, String> {
597 let reason = reason.trim();
598 if reason.is_empty() {
599 return Err("give the reason a client will print with `--reason`".to_string());
600 }
601 if reason.chars().count() > super::protocol::WITHDRAWN_MAX {
602 return Err(format!(
603 "the reason is limited to {} characters",
604 super::protocol::WITHDRAWN_MAX
605 ));
606 }
607 if reason.chars().any(char::is_control) {
608 return Err("the reason must be one line of text, with no control characters".to_string());
609 }
610 let config = read_config(dir)?;
611 if config.dl.contains("://") {
612 return Err(format!(
613 "registry `{}` keeps its archives at `{}`, which isn't a directory this command can delete from",
614 config.id, config.dl
615 ));
616 }
617 let index_path = dir.join(package);
618 if !index_path.is_file() {
619 return Err(format!("the registry has no package `{package}`"));
620 }
621 let mut lines = read_lines(&index_path)?;
622 let Some(line) = lines.iter_mut().find(|l| l.version == version) else {
623 return Err(format!("the registry has no `{package}` {version}"));
624 };
625 let (name, checksum) = (line.name.clone(), line.checksum.clone());
626 let hex = bynk_project::lock::checksum_hex(&checksum)
631 .ok_or_else(|| format!("`{package}` {version}'s checksum is malformed"))?;
632 let archive = dir.join(&config.dl).join("sha256").join(hex);
633 let mut shared = None;
634 for path in index_files(dir, &config)? {
635 for other in read_lines(&path)? {
636 let itself = other.name == name && other.version == version;
637 if !itself && other.checksum == checksum && other.withdrawn.is_none() {
638 shared.get_or_insert(format!("{} {}", other.name, other.version));
639 }
640 }
641 }
642 if line.withdrawn.is_some() {
643 if shared.is_none() && archive.is_file() {
646 return delete_archive(&archive).map(|_| ArchiveOutcome::Finished);
647 }
648 return Err(format!("`{package}` {version} is already withdrawn"));
649 }
650 line.yanked = true;
651 line.withdrawn = Some(reason.to_string());
652 let out: String = lines.iter().map(|l| l.render() + "\n").collect();
653 write_replacing(&index_path, out.as_bytes())
654 .map_err(|e| format!("could not write `{}`: {e}", index_path.display()))?;
655 match shared {
656 Some(other) => Ok(ArchiveOutcome::Shared(other)),
657 None => delete_archive(&archive),
658 }
659}
660
661fn delete_archive(archive: &Path) -> Result<ArchiveOutcome, String> {
662 match std::fs::remove_file(archive) {
663 Ok(()) => Ok(ArchiveOutcome::Deleted),
664 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ArchiveOutcome::Missing),
665 Err(e) => Err(format!(
666 "could not delete `{}`: {e} — run `bynk registry withdraw` again to finish",
667 archive.display()
668 )),
669 }
670}
671
672pub fn add_dependency(project: &Path, spec: &str, alias: Option<&str>) -> Result<String, String> {
676 let (package, range) = match spec.split_once('@') {
677 Some((p, r)) => (p.to_string(), Some(r.to_string())),
678 None => (spec.to_string(), None),
679 };
680 if let Some(problem) = bynk_project::manifest::package_name_problem(&package) {
681 return Err(format!("`{package}` {problem}"));
682 }
683 let (org, pkg) = package.split_once('/').expect("checked");
684 let alias = alias.unwrap_or(pkg).to_string();
685 if let Some(problem) = bynk_project::manifest::identifier_problem(&alias) {
686 return Err(format!(
687 "the alias `{alias}` {problem} — choose another with `--as`"
688 ));
689 }
690 let root = bynk_project::lock::lock_root(project);
691 let configs = registries_of(&root);
692 let mut client = super::Client::new(configs, None);
693 let registry = client.route(org);
694 let lines = client.lines(®istry, &package).map_err(|e| e.message)?;
695 let toolchain =
696 semver::Version::parse(crate::DRIVER_VERSION).unwrap_or(semver::Version::new(0, 0, 0));
697 let ranges: Vec<String> = range.iter().cloned().collect();
698 let chosen =
699 super::select::choose(&lines, &ranges, None, &toolchain).map_err(|_| match &range {
700 Some(r) => format!("registry `{registry}` has no `{package}` matching `{r}`"),
701 None => format!("registry `{registry}` has no usable version of `{package}`"),
702 })?;
703 let written = range.unwrap_or_else(|| format!("^{}", chosen.version));
704 let manifest = project.join("bynk.toml");
705 let text = std::fs::read_to_string(&manifest)
706 .map_err(|e| format!("could not read `bynk.toml`: {e}"))?;
707 let mut doc: toml_edit::DocumentMut = text
708 .parse()
709 .map_err(|_| "`bynk.toml` isn't valid TOML".to_string())?;
710 if doc.get("dependencies").is_none() {
711 doc["dependencies"] = toml_edit::table();
712 }
713 let deps = doc["dependencies"]
714 .as_table_mut()
715 .ok_or("`[dependencies]` isn't a table")?;
716 if deps.contains_key(&alias) {
717 return Err(format!(
718 "`[dependencies]` already has `{alias}` — choose another alias with `--as`"
719 ));
720 }
721 let mut entry = toml_edit::InlineTable::new();
722 entry.insert("package", package.clone().into());
723 entry.insert("version", written.clone().into());
724 deps.insert(&alias, toml_edit::value(entry));
725 std::fs::write(&manifest, doc.to_string())
726 .map_err(|e| format!("could not write `bynk.toml`: {e}"))?;
727 Ok(format!(
728 "{alias} = {{ package = \"{package}\", version = \"{written}\" }}"
729 ))
730}
731
732pub(crate) fn registries_of(root: &Path) -> Vec<bynk_project::RegistryConfig> {
734 crate::fetch::read_manifest(root)
735 .and_then(|doc| {
736 doc.get("registries")
737 .and_then(|v| v.as_table())
738 .and_then(|t| bynk_project::manifest::registries_from(t).ok())
739 })
740 .unwrap_or_default()
741}
742
743pub(crate) fn read_config(dir: &Path) -> Result<Config, String> {
744 let bytes = std::fs::read(dir.join("config.json")).map_err(|_| {
745 format!(
746 "`{}` isn't a registry: it has no `config.json` (run `bynk registry init`)",
747 dir.display()
748 )
749 })?;
750 Config::parse(&bytes)
751}
752
753pub(crate) fn signing_key(path: &Path) -> Result<SigningKey, String> {
754 let bytes = std::fs::read(path)
755 .map_err(|e| format!("could not read the key `{}`: {e}", path.display()))?;
756 SigningKey::from_pkcs8(&bytes)
757}
758
759pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> Result<(), String> {
760 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
761 std::fs::create_dir_all(parent).map_err(|e| format!("could not write the key: {e}"))?;
762 }
763 std::fs::write(path, bytes).map_err(|e| format!("could not write the key: {e}"))?;
764 #[cfg(unix)]
765 {
766 use std::os::unix::fs::PermissionsExt;
767 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
768 }
769 Ok(())
770}
771
772pub fn default_key_path(id: &str) -> PathBuf {
774 PathBuf::from(format!("{id}.key"))
775}