ephemeris_release/adapters/cargo/
publisher.rs1use 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#[derive(Debug)]
17pub(crate) struct CargoPublisher<R> {
18 registry: R,
19 authorization: Option<Token>,
20}
21
22#[derive(Debug, thiserror::Error)]
24pub(crate) enum PackageError {
25 #[error("cargo unit {unit:?} publishes but no registry API URL is configured")]
27 MissingRegistry {
28 unit: String,
30 },
31 #[error("invalid Cargo registry API URL {url:?}")]
33 InvalidApiUrl {
34 url: String,
36 #[source]
38 source: url::ParseError,
39 },
40 #[error("no credential was given to publish Cargo packages")]
42 MissingToken,
43 #[error(transparent)]
45 Crate(Box<CrateError>),
46 #[error("crate upload failed")]
48 Upload(#[source] Box<RegistryError>),
49 #[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 #[must_use]
67 pub(crate) const fn new(registry: R) -> Self {
68 Self {
69 registry,
70 authorization: None,
71 }
72 }
73
74 #[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 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 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}