Skip to main content

ephemeris_release/adapters/cargo/
publisher.rs

1//! Cargo package publication through the registry web API.
2
3use std::path::Path;
4
5use ephemeris_token::Token;
6use reqwest::Url;
7
8use super::package::{CrateError, CratePackage};
9use crate::PackageRegistry;
10use crate::adapters::registry::{HttpRegistry, RegistryError};
11use crate::error::PackageFailure;
12use crate::state::ReleaseTarget;
13use crate::unit::Unit;
14
15/// Cargo-specific package publication.
16#[derive(Debug)]
17pub(crate) struct CargoPublisher<R> {
18    registry: R,
19    authorization: Option<Token>,
20}
21
22/// Crate upload or package-registry failure.
23#[derive(Debug, thiserror::Error)]
24pub(crate) enum PackageError {
25    /// A unit publishes but the registry has no base URL.
26    #[error("cargo unit {unit:?} publishes but no registry API URL is configured")]
27    MissingRegistry {
28        /// The release unit.
29        unit: String,
30    },
31    /// The registry base URL cannot address the upload path.
32    #[error("invalid Cargo registry API URL {url:?}")]
33    InvalidApiUrl {
34        /// The configured URL.
35        url: String,
36        /// The parse failure.
37        #[source]
38        source: url::ParseError,
39    },
40    /// The publisher was built without a credential.
41    #[error("no credential was given to publish Cargo packages")]
42    MissingToken,
43    /// The crate file cannot be turned into an upload.
44    #[error(transparent)]
45    Crate(Box<CrateError>),
46    /// The upload request failed.
47    #[error("crate upload failed")]
48    Upload(#[source] Box<RegistryError>),
49    /// The registry could not say whether a version exists.
50    #[error("Cargo registry lookup failed")]
51    Registry(#[from] PackageFailure),
52}
53
54impl PackageError {
55    fn crate_file(error: CrateError) -> Self {
56        Self::Crate(Box::new(error))
57    }
58
59    fn upload(error: RegistryError) -> Self {
60        Self::Upload(Box::new(error))
61    }
62}
63
64impl<R> CargoPublisher<R> {
65    /// Bind the registry that lookups and uploads go to.
66    #[must_use]
67    pub(crate) const fn new(registry: R) -> Self {
68        Self {
69            registry,
70            authorization: None,
71        }
72    }
73
74    /// Send `authorization` as the whole `Authorization` value of uploads.
75    #[must_use]
76    pub(crate) fn authenticated(mut self, authorization: Token) -> Self {
77        self.authorization = Some(authorization);
78        self
79    }
80}
81
82impl<R: PackageRegistry> CargoPublisher<R> {
83    pub(crate) async fn is_published(&self, unit: &Unit) -> Result<bool, PackageError> {
84        if !publishes(unit) {
85            return Ok(true);
86        }
87        self.registry
88            .package_exists(package_name(unit), &unit.version.to_string())
89            .await
90            .map_err(|error| PackageError::Registry(PackageFailure::new(error)))
91    }
92
93    pub(crate) async fn publish(&self, unit: &Unit, file: &Path) -> Result<(), PackageError> {
94        if !publishes(unit) {
95            return Ok(());
96        }
97        let token = self
98            .authorization
99            .as_ref()
100            .ok_or(PackageError::MissingToken)?;
101        self.publish_with(token, unit, file).await
102    }
103
104    async fn publish_with(
105        &self,
106        token: &Token,
107        unit: &Unit,
108        file: &Path,
109    ) -> Result<(), PackageError> {
110        let base = self
111            .registry
112            .base_url()
113            .ok_or_else(|| PackageError::MissingRegistry {
114                unit: unit.name.clone(),
115            })?;
116        let url = upload_url(base)?;
117        let package = CratePackage::read(
118            file,
119            package_name(unit),
120            &unit.version.to_string(),
121            base.as_str(),
122        )
123        .map_err(PackageError::crate_file)?;
124        let body = package.body().map_err(PackageError::crate_file)?;
125        let http = HttpRegistry::bare(token).map_err(PackageError::upload)?;
126        let Err(source) = http.put(&url, body).await else {
127            return Ok(());
128        };
129        // An upload that reached the registry but reported failure, for
130        // example on a timeout, counts as done once the version is visible.
131        if self
132            .is_published(unit)
133            .await
134            .is_ok_and(|published| published)
135        {
136            Ok(())
137        } else {
138            Err(PackageError::upload(source))
139        }
140    }
141}
142
143fn upload_url(base: &Url) -> Result<Url, PackageError> {
144    base.join("api/v1/crates/new")
145        .map_err(|source| PackageError::InvalidApiUrl {
146            url: base.to_string(),
147            source,
148        })
149}
150
151fn package_name(unit: &Unit) -> &str {
152    unit.package_name.as_deref().unwrap_or(&unit.name)
153}
154
155const fn publishes(unit: &Unit) -> bool {
156    matches!(
157        &unit.target,
158        ReleaseTarget::Cargo {
159            publish: true,
160            manifest: _
161        }
162    )
163}
164
165#[cfg(test)]
166mod tests {
167    use std::cell::Cell;
168    use std::collections::BTreeMap;
169    use std::convert::Infallible;
170    use std::fs;
171    use std::path::{Path, PathBuf};
172
173    use ephemeris_loopback::{Response, Server};
174    use ephemeris_token::Token;
175    use flate2::Compression;
176    use flate2::write::GzEncoder;
177    use reqwest::Url;
178
179    use super::{CargoPublisher, PackageError, upload_url};
180    use crate::PackageRegistry;
181    use crate::state::ReleaseTarget;
182    use crate::unit::Unit;
183
184    /// Reports the version as published from the given lookup on.
185    struct VisibleFrom {
186        lookups: Cell<usize>,
187        first_visible: usize,
188        base: Option<Url>,
189    }
190
191    impl PackageRegistry for VisibleFrom {
192        type Error = Infallible;
193
194        async fn package_exists(&self, _: &str, _: &str) -> Result<bool, Self::Error> {
195            let lookup = self.lookups.get();
196            self.lookups.set(lookup.saturating_add(1));
197            Ok(lookup >= self.first_visible)
198        }
199
200        fn base_url(&self) -> Option<&Url> {
201            self.base.as_ref()
202        }
203    }
204
205    fn unit(publish: bool) -> Unit {
206        Unit {
207            name: "core".into(),
208            package_name: Some("core".into()),
209            version: "1.0.0".parse().unwrap(),
210            target: ReleaseTarget::Cargo {
211                manifest: "core/Cargo.toml".parse().unwrap(),
212                publish,
213            },
214            source: vec!["core".parse().unwrap()].try_into().unwrap(),
215            changelog: Some("core/CHANGELOG.md".parse().unwrap()),
216            tag_prefix: "core-v".into(),
217            dependencies: Vec::new(),
218            pins: BTreeMap::new(),
219            revision: None,
220        }
221    }
222
223    fn crate_file(directory: &Path) -> PathBuf {
224        let manifest = "[package]\nname = \"core\"\nversion = \"1.0.0\"\n";
225        let mut archive = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
226        let mut header = tar::Header::new_gnu();
227        header.set_size(manifest.len() as u64);
228        header.set_mode(0o644);
229        header.set_cksum();
230        archive
231            .append_data(&mut header, "core-1.0.0/Cargo.toml", manifest.as_bytes())
232            .unwrap();
233        let path = directory.join("core.crate");
234        fs::write(&path, archive.into_inner().unwrap().finish().unwrap()).unwrap();
235        path
236    }
237
238    fn publisher(first_visible: usize, base: Option<&str>) -> CargoPublisher<VisibleFrom> {
239        CargoPublisher::new(VisibleFrom {
240            lookups: Cell::new(0),
241            first_visible,
242            base: base.map(|base| base.parse().unwrap()),
243        })
244    }
245
246    #[tokio::test]
247    async fn the_upload_goes_under_the_registry_api_with_the_bare_token() {
248        let server = Server::start(vec![Response::new(200, r#"{"warnings":{"other":["x"]}}"#)]);
249        let directory = tempfile::tempdir().unwrap();
250        let api = format!("{}api/packages/acme/cargo/", server.url());
251        let publisher = publisher(usize::MAX, Some(&api));
252        publisher
253            .publish_with(
254                &Token::new("secret"),
255                &unit(true),
256                &crate_file(directory.path()),
257            )
258            .await
259            .unwrap();
260        let requests = server.requests();
261        assert_eq!(requests.len(), 1);
262        assert_eq!(requests[0].method, "PUT");
263        assert_eq!(
264            requests[0].path,
265            "/api/packages/acme/cargo/api/v1/crates/new"
266        );
267        assert_eq!(requests[0].header("authorization"), Some("secret"));
268        assert_eq!(publisher.registry.lookups.get(), 0);
269    }
270
271    #[tokio::test]
272    async fn the_given_authorization_is_sent_as_is() {
273        let server = Server::start(vec![Response::new(200, "{}")]);
274        let directory = tempfile::tempdir().unwrap();
275        let publisher = publisher(usize::MAX, Some(server.url().as_str()))
276            .authenticated(Token::new("Bearer secret"));
277        publisher
278            .publish(&unit(true), &crate_file(directory.path()))
279            .await
280            .unwrap();
281        assert_eq!(
282            server.requests()[0].header("authorization"),
283            Some("Bearer secret")
284        );
285    }
286
287    #[tokio::test]
288    async fn a_publisher_without_a_credential_cannot_publish() {
289        let directory = tempfile::tempdir().unwrap();
290        assert!(matches!(
291            publisher(usize::MAX, None)
292                .publish(&unit(true), &crate_file(directory.path()))
293                .await,
294            Err(PackageError::MissingToken)
295        ));
296    }
297
298    #[tokio::test]
299    async fn failed_upload_of_a_version_now_visible_counts_as_published() {
300        let server = Server::start(vec![Response::new(500, "timeout")]);
301        let directory = tempfile::tempdir().unwrap();
302        let publisher = publisher(0, Some(server.url().as_str()));
303        publisher
304            .publish_with(
305                &Token::new("secret"),
306                &unit(true),
307                &crate_file(directory.path()),
308            )
309            .await
310            .unwrap();
311        assert_eq!(publisher.registry.lookups.get(), 1);
312    }
313
314    #[tokio::test]
315    async fn failed_upload_of_an_absent_version_is_an_error() {
316        let server = Server::start(vec![Response::new(400, "bad crate")]);
317        let directory = tempfile::tempdir().unwrap();
318        let publisher = publisher(usize::MAX, Some(server.url().as_str()));
319        assert!(matches!(
320            publisher
321                .publish_with(
322                    &Token::new("secret"),
323                    &unit(true),
324                    &crate_file(directory.path())
325                )
326                .await,
327            Err(PackageError::Upload(_))
328        ));
329    }
330
331    #[tokio::test]
332    async fn an_upload_without_a_registry_api_url_is_an_error() {
333        let directory = tempfile::tempdir().unwrap();
334        assert!(matches!(
335            publisher(0, None).publish_with(
336                &Token::new("secret"),
337                &unit(true),
338                &crate_file(directory.path())
339            ).await,
340            Err(PackageError::MissingRegistry { unit }) if unit == "core"
341        ));
342    }
343
344    #[tokio::test]
345    async fn a_crate_that_does_not_read_is_an_error_before_any_request() {
346        let server = Server::start(Vec::new());
347        let directory = tempfile::tempdir().unwrap();
348        let publisher = publisher(0, Some(server.url().as_str()));
349        assert!(matches!(
350            publisher
351                .publish_with(
352                    &Token::new("secret"),
353                    &unit(true),
354                    &directory.path().join("absent.crate")
355                )
356                .await,
357            Err(PackageError::Crate(_))
358        ));
359        assert!(server.requests().is_empty());
360    }
361
362    #[tokio::test]
363    async fn a_unit_that_does_not_publish_counts_as_published() {
364        let publisher = publisher(usize::MAX, None);
365        assert!(publisher.is_published(&unit(false)).await.unwrap());
366        assert_eq!(publisher.registry.lookups.get(), 0);
367        assert!(!publisher.is_published(&unit(true)).await.unwrap());
368    }
369
370    #[test]
371    fn the_upload_url_adds_the_api_path_to_the_registry_base() {
372        let base = "https://forge.example/api/packages/acme/cargo/"
373            .parse()
374            .unwrap();
375        assert_eq!(
376            upload_url(&base).unwrap().as_str(),
377            "https://forge.example/api/packages/acme/cargo/api/v1/crates/new"
378        );
379    }
380}