Skip to main content

bynk/registry/
commands.rs

1//! The reference static registry and `bynk add` (packaging slice 4a, #2274,
2//! decisions H and I).
3//!
4//! A static registry is a directory any static host can serve:
5//!
6//! ```text
7//! <dir>/config.json             protocol, id, auth, the key chain
8//! <dir>/<org>/<package>         one signed JSON line per version (append-only)
9//! <dir>/dl/sha256/<hex>         each version's archive, named by tree checksum
10//! ```
11//!
12//! `bynk registry init` writes `config.json` and a signing key (never inside
13//! the directory); `bynk registry add` packs a package, signs its line and
14//! appends it; `bynk registry yank` flips a line's `yanked`, which the
15//! signature doesn't cover, so nothing is re-signed and nothing is deleted.
16//! `bynk registry withdraw` (#2330) is the takedown: it yanks a line, records
17//! why in its unsigned `withdrawn`, and deletes its archive.
18
19use 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
27/// `bynk registry init`: a new static registry at `dir` with id `id`, its
28/// signing key written to `key_out`. Returns the `key = "…"` line consumers
29/// pin.
30pub 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
69/// `bynk registry add`: pack the package at `package_dir`, sign its line with
70/// the key in `key_file`, and append it to the registry at `dir`. Returns
71/// `org/package version`.
72pub 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/// #2311 (D10): what admitting an upload produced — the line, and whether it
93/// is new (`fresh`) or an identical tree's existing line (a retry).
94#[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
107/// #2287: admit an uploaded archive to the static registry at `dir` — the one
108/// set of rules `bynk registry add` and `bynk registry serve` both apply.
109///
110/// The archive is admitted in memory by `bynk-registry-core` (the one
111/// implementation the hosted registry's wasm module shares): its own `bynk.toml`
112/// must name a `[package]` with a `version` (and, when `expected` is given,
113/// the very `org/package` and version the request named), its dependencies
114/// must all be registry dependencies, and the version must be new — or, an
115/// existing version whose tree is the very one uploaded, which answers with
116/// its existing line and changes nothing (#2311, D10: a retry). The tree is
117/// re-packed canonically, its archive written before its line, and the index
118/// rewritten through a rename, so a crash never leaves a line without its
119/// archive. The caller serialises calls for one registry.
120pub(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            // #2330 (E): a withdrawn version stays down — even a retry of the
157            // very tree must not quietly restore a takedown.
158            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
204/// Write `bytes` to `path` through a temporary file and a rename, so a reader
205/// (or a crash) never sees half a file.
206pub(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
215/// Refuse a key that isn't the registry's current (newest) key.
216pub(crate) fn current_key(
217    config: &Config,
218    key: &SigningKey,
219    key_file: &Path,
220) -> Result<(), String> {
221    // #2316 (E): the chain's newest key, or a valid, unrevoked delegate.
222    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
243/// #2316: the registry's own chain, every link verified from its first key —
244/// the keys an operator's own tooling trusts.
245fn 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
262/// #2316: who may sign lines as a client pinned to the **newest** chain key
263/// sees it — the strictest view, since a client anchored earlier reaches the
264/// newest key too. The tooling judges delegates by it, so a delegate a fresh
265/// pin wouldn't accept is never treated as valid.
266fn 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
272/// #2316: the key in `key_file`, which must be the registry's **newest**
273/// chain key: only it may delegate or revoke. (A client pinned to a later link
274/// trusts only the chain from there, so a delegation an older link signed
275/// would be invalid for it.)
276fn 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
289/// A new key written to `out`, refused inside the registry directory.
290fn 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    // Check what is about to be published reads back as written.
308    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
316/// `bynk registry rotate` (#2316): append a chain link — a new key, signed by
317/// the current (newest) chain key in `key_file`. Returns the new key.
318pub 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    // A client that pins the new key trusts only it, so carry every valid
335    // delegation and revocation over by re-signing it with the new key; one
336    // that wasn't valid stays as it was, ignored.
337    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
356/// `bynk registry delegate` (#2316): a new key that may sign index lines,
357/// delegated by the chain key in `key_file`. Returns the new key.
358pub 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
377/// `bynk registry revoke` (#2316): revoke the delegate `target`, signed by the
378/// chain key in `key_file`. Refuses, unless `force`, when a line would be
379/// left with no valid signer. Returns how many lines it signed.
380pub 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
422/// `bynk registry resign` (#2316): re-sign every index line with the key in
423/// `key_file` (the newest chain key, or a valid delegate). Returns the number
424/// of lines re-signed. Only `sig` changes.
425pub 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    // Re-sign only what a key the registry knows signed — the chain, a valid
430    // delegate, or a revoked one (replacing those is the point) — so a line
431    // planted in the directory is never laundered into a valid one.
432    //
433    // Each file is read once, and what was checked is exactly what is signed
434    // and written. Don't run it while a server appends to the same directory:
435    // a line appended between the read and the write would be lost.
436    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
478/// Every index file under `dir`: `<org>/<package>`, outside the archive base.
479fn 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
519/// `bynk registry yank`: set (or, with `undo`, clear) `yanked` on one line.
520pub 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
524/// #2287: flip `yanked` on one line — `bynk registry yank` and the publish
525/// API's yank. Nothing is deleted or re-signed; `yanked` isn't signed.
526pub(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            // #2330 (E): un-yanking a withdrawn version would advertise a
545            // version whose archive is gone.
546            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/// #2330 (D): what `bynk registry withdraw` did to the archive.
571#[derive(Debug, Clone, PartialEq, Eq)]
572pub enum ArchiveOutcome {
573    /// The archive was deleted.
574    Deleted,
575    /// Another line names the same tree, so its archive was kept; carries
576    /// that line's `org/package version`.
577    Shared(String),
578    /// There was no archive to delete.
579    Missing,
580    /// The version was already withdrawn but its archive was still there (an
581    /// interrupted withdrawal); it was deleted now.
582    Finished,
583}
584
585/// #2330 (D): `bynk registry withdraw` — the takedown of `package` `version`
586/// on the registry at `dir`. Sets `yanked` and `withdrawn` (`reason`) on its
587/// line, re-signing nothing, then deletes its archive unless another line
588/// names the same tree. Final: a withdrawn version is refused here (unless an
589/// interrupted withdrawal left its archive behind, which is then deleted), by
590/// `add`, by the publish API and by an un-yank.
591pub 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    // Everything that can fail is read before anything is written, so a
627    // refusal leaves the registry as it was. Identical trees under two
628    // versions share one archive: keep it while another line, not itself
629    // withdrawn, still names it.
630    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        // A withdrawal interrupted after its index write left the archive
644        // behind: finish it rather than leave the takedown half done.
645        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
672/// `bynk add`: add `package` (`org/package`, optionally `@range`) to the
673/// manifest at `project`, choosing the highest version its registry offers.
674/// Returns the line written.
675pub 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(&registry, &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
732/// The `[registries]` of the manifest at `root`.
733pub(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
772/// The default key path for registry `id`: `./<id>.key`.
773pub fn default_key_path(id: &str) -> PathBuf {
774    PathBuf::from(format!("{id}.key"))
775}