Skip to main content

bynk/registry/
publish.rs

1//! `bynk publish` and `bynk yank` (packaging slice 4c, #2287, decisions B and
2//! F): the client side of the publish API.
3//!
4//! `publish` checks the package, packs it, uploads it to the registry its
5//! organisation routes to, and then reads the new line back through the
6//! ordinary verifying client — so a server that signed anything other than
7//! what was sent is caught at once.
8
9use std::path::Path;
10
11use bynk_project::manifest::{DependencySource, package_config_from};
12
13use super::archive;
14use super::{Client, TokenSource};
15use crate::fetch::{FetchError, fail};
16
17pub(crate) const PUBLISH_REFUSED: &str = "bynk.registry.publish_refused";
18
19/// What `publish` did.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Published {
22    pub name: String,
23    pub version: String,
24    pub checksum: String,
25    pub registry: String,
26    /// Nothing was uploaded (`--dry-run`).
27    pub dry_run: bool,
28}
29
30/// Publish the package at `project` (which must already pass `bynk check`).
31pub fn publish(project: &Path, dry_run: bool) -> Result<Published, FetchError> {
32    let doc = crate::fetch::read_manifest(project)
33        .ok_or_else(|| fail(PUBLISH_REFUSED, "there is no readable `bynk.toml` here"))?;
34    let package = doc
35        .get("package")
36        .and_then(|v| v.as_table())
37        .and_then(|t| package_config_from(t).ok())
38        .ok_or_else(|| {
39            fail(
40                PUBLISH_REFUSED,
41                "this project isn't a package: `bynk.toml` has no `[package]`",
42            )
43        })?;
44    let version = package.version.clone().ok_or_else(|| {
45        fail(
46            PUBLISH_REFUSED,
47            "`[package]` has no `version`, which publishing needs",
48        )
49    })?;
50    for spec in crate::fetch::specs(&doc, "dependencies") {
51        if !matches!(spec.source, DependencySource::Version(_)) {
52            return Err(fail(
53                PUBLISH_REFUSED,
54                format!(
55                    "the dependency `{}` is a path or git dependency, which a registry refuses — publish it, and depend on it by `version`",
56                    spec.alias
57                ),
58            ));
59        }
60    }
61    let name = format!("{}/{}", package.org, package.package);
62    let (bytes, checksum) = archive::pack(project)
63        .map_err(|e| fail(PUBLISH_REFUSED, format!("could not pack the package: {e}")))?;
64    let (mut client, registry) = client_for(project, &package.org);
65    // A dry run refuses what the real one would.
66    client.writable(&registry)?;
67    let mut out = Published {
68        name: name.clone(),
69        version: version.clone(),
70        checksum: checksum.clone(),
71        registry: registry.clone(),
72        dry_run,
73    };
74    if dry_run {
75        return Ok(out);
76    }
77    let (status, body) =
78        client.api_write(&registry, "PUT", &format!("{name}/{version}"), Some(&bytes))?;
79    if status != 201 && status != 200 {
80        return Err(refused(&client, &registry, &package.org, status, &body));
81    }
82    // Read it back as any consumer would.
83    client.forget(&registry, &name);
84    let lines = client.lines(&registry, &name)?;
85    let line = lines.iter().find(|l| l.version == version).ok_or_else(|| {
86        fail(
87            PUBLISH_REFUSED,
88            format!(
89                "registry `{registry}` accepted `{name}` {version}, but its index doesn't list it"
90            ),
91        )
92    })?;
93    if line.checksum != checksum {
94        return Err(fail(
95            crate::fetch::CHECKSUM_MISMATCH,
96            format!(
97                "registry `{registry}` signed `{name}` {version} as the tree {}, but bynk sent {checksum}",
98                line.checksum
99            ),
100        ));
101    }
102    out.checksum = line.checksum.clone();
103    Ok(out)
104}
105
106/// Yank (or, with `undo`, un-yank) `package` `version` through its
107/// registry's API, and confirm it by reading the index back.
108pub fn yank(
109    project: &Path,
110    package: &str,
111    version: &str,
112    undo: bool,
113) -> Result<String, FetchError> {
114    let org = package.split('/').next().unwrap_or_default();
115    let (mut client, registry) = client_for(project, org);
116    let verb = if undo { "unyank" } else { "yank" };
117    let (status, body) = client.api_write(
118        &registry,
119        "POST",
120        &format!("{package}/{version}/{verb}"),
121        None,
122    )?;
123    if !(200..300).contains(&status) {
124        return Err(refused(&client, &registry, org, status, &body));
125    }
126    client.forget(&registry, package);
127    let lines = client.lines(&registry, package)?;
128    match lines.iter().find(|l| l.version == version) {
129        Some(l) if l.yanked == undo => Err(fail(
130            PUBLISH_REFUSED,
131            format!("registry `{registry}` accepted the {verb}, but its index doesn't show it"),
132        )),
133        Some(_) => Ok(registry),
134        None => Err(fail(
135            PUBLISH_REFUSED,
136            format!("registry `{registry}` has no `{package}` {version}"),
137        )),
138    }
139}
140
141/// A client over the project's (or workspace's) `[registries]` and lock,
142/// acting for `org` (R3.2, #2383), and the registry `org` routes to.
143fn client_for(project: &Path, org: &str) -> (Client, String) {
144    let root = bynk_project::lock::lock_root(project);
145    let lock = std::fs::read_to_string(root.join(bynk_project::lock::LOCK_FILE))
146        .ok()
147        .and_then(|t| bynk_project::lock::parse_lock(&t).ok());
148    let client = Client::new(super::commands::registries_of(&root), lock.as_ref()).acting_for(org);
149    let registry = client.route(org);
150    (client, registry)
151}
152
153/// A refusal from the API, with its JSON `error` and `message` when present,
154/// and — R3.2 (#2383, E) — where the refused token came from.
155fn refused(client: &Client, registry: &str, org: &str, status: u16, body: &[u8]) -> FetchError {
156    let parsed: Option<(String, String)> = serde_json::from_slice::<serde_json::Value>(body)
157        .ok()
158        .and_then(|v| {
159            Some((
160                v.get("error")?.as_str()?.to_owned(),
161                v.get("message")?.as_str()?.to_owned(),
162            ))
163        });
164    let source = client.token_source(registry);
165    let hint = token_hint(registry, org, status, source);
166    let why = match parsed {
167        Some((code, message)) => {
168            format!("registry `{registry}` refused it (HTTP {status}, `{code}`): {message}")
169        }
170        None if status == 401 => format!("registry `{registry}` needs a publish token (HTTP 401)"),
171        None => format!("registry `{registry}` refused it (HTTP {status})"),
172    };
173    fail(PUBLISH_REFUSED, format!("{why}{hint}"))
174}
175
176/// What to do about a `401` or `403`: where the token came from, and how to
177/// give `org` its own.
178fn token_hint(registry: &str, org: &str, status: u16, source: Option<&TokenSource>) -> String {
179    let save = format!("save one for `{org}` with `bynk org token mint {org} --save`");
180    match (status, source) {
181        (401, None) => format!(
182            "\n  = note: no token was found: set `{}`, or {save}",
183            bynk_project::manifest::registry_token_var(registry)
184        ),
185        (401, Some(source @ TokenSource::Organisation { .. })) => format!(
186            "\n  = note: the token came from {source}; if it expired or was revoked, replace it with `bynk org token mint {org} --save --force`"
187        ),
188        (403, Some(source @ TokenSource::Organisation { .. })) => {
189            format!("\n  = note: the token came from {source}")
190        }
191        (401 | 403, Some(source @ TokenSource::Var(_))) => format!(
192            "\n  = note: the token came from {source}, which is used instead of any saved token"
193        ),
194        (401 | 403, Some(source @ TokenSource::Registry(_))) => {
195            format!("\n  = note: the token came from {source}; {save}")
196        }
197        _ => String::new(),
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn a_refused_token_s_source_is_named() {
207        let var = TokenSource::Var("BYNK_REGISTRY_BYNK_TOKEN".into());
208        let wide = TokenSource::Registry("bynk".into());
209        let own = TokenSource::Organisation {
210            registry: "bynk".into(),
211            org: "acme".into(),
212        };
213        assert_eq!(
214            token_hint("bynk", "acme", 401, None),
215            "\n  = note: no token was found: set `BYNK_REGISTRY_BYNK_TOKEN`, or save one for `acme` with `bynk org token mint acme --save`"
216        );
217        assert_eq!(
218            token_hint("bynk", "acme", 403, Some(&wide)),
219            "\n  = note: the token came from `[registries.bynk] token`; save one for `acme` with `bynk org token mint acme --save`"
220        );
221        assert_eq!(
222            token_hint("bynk", "acme", 403, Some(&own)),
223            "\n  = note: the token came from `[registries.bynk.organisations.acme] token`"
224        );
225        assert!(token_hint("bynk", "acme", 401, Some(&var)).contains("instead of any saved token"));
226        assert!(
227            token_hint("bynk", "acme", 401, Some(&own))
228                .ends_with("`bynk org token mint acme --save --force`")
229        );
230        assert_eq!(token_hint("bynk", "acme", 409, Some(&own)), "");
231    }
232}