1use std::{env, io};
2
3use crate::lua_rockspec::RemoteLuaRockspec;
4use crate::operations::SearchAndDownloadError;
5use crate::package::{PackageName, PackageVersion, SpecRevIterator};
6use crate::package_db::PackageDB;
7use crate::project::project_toml::{ProjectTomlError, RemoteProjectTomlValidationError};
8use crate::rockspec::Rockspec;
9use crate::TOOL_VERSION;
10use crate::{config::Config, project::Project};
11
12use bon::Builder;
13use itertools::Itertools;
14use miette::Diagnostic;
15use reqwest::multipart::{Form, Part};
16use reqwest::StatusCode;
17use serde::Deserialize;
18use serde_enum_str::Serialize_enum_str;
19use thiserror::Error;
20use url::Url;
21
22#[cfg(feature = "gpgme")]
23use gpgme::{Context, Data};
24#[cfg(feature = "gpgme")]
25use std::io::Read;
26
27const TFA_TOKEN_HEADER: &str = "X-TFA-Token";
28
29#[derive(Builder)]
32#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
33pub struct ProjectUpload<'a> {
34 project: &'a Project,
35 api_key: Option<ApiKey>,
36 tfa_code: Option<String>,
37 #[cfg(feature = "gpgme")]
38 sign_protocol: SignatureProtocol,
39 config: &'a Config,
40 package_db: &'a PackageDB,
41}
42
43impl<State> ProjectUploadBuilder<'_, State>
44where
45 State: project_upload_builder::State + project_upload_builder::IsComplete,
46{
47 pub async fn upload_to_luarocks(self) -> Result<(), UploadError> {
49 let args = self._build();
50 upload_from_project(args).await
51 }
52}
53
54#[derive(Builder)]
56#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
57pub struct RockspecUpload<'a> {
58 rockspec: &'a RemoteLuaRockspec,
59 api_key: Option<ApiKey>,
60 tfa_code: Option<String>,
61 #[cfg(feature = "gpgme")]
62 sign_protocol: SignatureProtocol,
63 config: &'a Config,
64}
65
66impl<State> RockspecUploadBuilder<'_, State>
67where
68 State: rockspec_upload_builder::State + rockspec_upload_builder::IsComplete,
69{
70 pub async fn upload_to_luarocks(self) -> Result<(), UploadError> {
72 let args = self._build();
73 upload_from_rockspec(args).await
74 }
75}
76
77#[derive(Deserialize, Debug)]
78pub struct VersionCheckResponse {
79 version: String,
80}
81
82#[derive(Error, Debug, Diagnostic)]
83#[non_exhaustive]
84pub enum ToolCheckError {
85 #[error("error parsing tool check URL")]
86 #[diagnostic(help("check your server configuration"))]
87 ParseError(#[from] url::ParseError),
88 #[error("error sending HTTP request")]
89 #[diagnostic(help(
90 r#"check your network connection and server configuration.
91if the issue persists, the server may be temporarily unavailable."#
92 ))]
93 Request(#[from] reqwest::Error),
94 #[error(r#"Lux is out of date with {0}'s expected tool version.
95 Lux is at version {TOOL_VERSION}, server is at {server_version}"#, server_version = _1.version)]
96 #[diagnostic(help("ensure you have an up-to-date version of Lux installed"))]
97 ToolOutdated(String, VersionCheckResponse),
98}
99
100#[derive(Error, Debug, Diagnostic)]
101#[non_exhaustive]
102pub enum UserCheckError {
103 #[error("error parsing user check URL")]
104 #[diagnostic(help("check your server configuration"))]
105 Parse(#[from] url::ParseError),
106 #[error(transparent)]
107 #[diagnostic(help(
108 r#"check your network connection and server configuration.
109if the issue persists, the server may be temporarily unavailable."#
110 ))]
111 Request(#[from] reqwest::Error),
112 #[diagnostic(help(
113 "ensure that the API key provided via the $LUX_API_KEY environemnt variable is correct"
114 ))]
115 #[error("invalid API key provided")]
116 UserNotFound,
117 #[error("server '{0}' responded with error status: '{1}'")]
118 #[diagnostic(help("the server may be temporarily unavailable"))]
119 Server(Url, StatusCode),
120}
121
122#[derive(Error, Debug, Diagnostic)]
123#[non_exhaustive]
124pub enum RockCheckError {
125 #[error("parse error while checking rock status on server")]
126 #[diagnostic(help("check your server configuration"))]
127 ParseError(#[from] url::ParseError),
128 #[error("HTTP request error while checking rock status on server")]
129 #[diagnostic(help(
130 r#"check your network connection and server configuration.
131if the issue persists, the server may be temporarily unavailable."#
132 ))]
133 Request(#[from] reqwest::Error),
134}
135
136#[derive(Error, Debug, Diagnostic)]
137#[non_exhaustive]
138#[error(transparent)]
139pub enum UploadError {
140 #[error("error parsing upload URL")]
141 #[diagnostic(help("check your server configuration"))]
142 ParseError(#[from] url::ParseError),
143 #[error("HTPP request error while uploading")]
144 #[diagnostic(help(
145 r#"check your network connection and server configuration.
146if the issue persists, the server may be temporarily unavailable."#
147 ))]
148 Request(#[from] reqwest::Error),
149 #[error("server '{0}' responded with error status: '{1}'")]
150 #[diagnostic(help("the server may be temporarily unavailable"))]
151 Server(Url, StatusCode),
152 #[error("client error when requesting {0}:\n{1}")]
153 #[diagnostic(help(
154 r#"check your network connection and server configuration.
155if the issue persists, the server may be temporarily unavailable."#
156 ))]
157 Client(Url, String),
158 #[diagnostic(transparent)]
159 RockCheck(#[from] RockCheckError),
160 #[error("a package with the same rockspec content already exists on the server: '{0}'")]
161 #[diagnostic(help("try uploading a rockspec with a different version"))]
162 RockExists(Url),
163 #[error("unable to read rockspec")]
164 #[diagnostic(help("check that the rockspec exists and is readable"))]
165 RockspecRead(#[from] std::io::Error),
166 #[cfg(feature = "gpgme")]
167 #[error("GPG signing failed")]
168 #[diagnostic(help(
169 r#"ensure that a GPG agent is running and that a valid GPG signing key is registered.
170 if you'd like to skip the signing step, supply `--sign-protocol=none`
171 "#
172 ))]
173 Signature(#[from] gpgme::Error),
174 #[error(transparent)]
175 #[diagnostic(transparent)]
176 ToolCheck(#[from] ToolCheckError),
177 #[error(transparent)]
178 #[diagnostic(transparent)]
179 UserCheck(#[from] UserCheckError),
180 #[error(transparent)]
181 #[diagnostic(transparent)]
182 ApiKeyUnspecified(#[from] ApiKeyUnspecified),
183 #[error(transparent)]
184 #[diagnostic(transparent)]
185 ValidationError(#[from] RemoteProjectTomlValidationError),
186 #[error("unsupported version: '{0}'")]
187 #[diagnostic(help("Lux can upload packages with a SemVer version, 'dev' or 'scm'"))]
188 UnsupportedVersion(String),
189 #[error(transparent)]
190 #[diagnostic(transparent)]
191 Rockspec(#[from] ProjectTomlError),
192 #[error("the maximum supported number of rockspec revisions per version has been exceeded")]
193 #[diagnostic(help("bump the version to a version that has not yet been published"))]
194 MaxSpecRevsExceeded,
195 #[error("rock already exists on server. Error downloading existing rockspec")]
196 #[diagnostic(forward(0))]
197 SearchAndDownload(#[from] Box<SearchAndDownloadError>),
198 #[error("error computing rockspec hash")]
199 Hash(io::Error),
200 #[error("the 2FA code '{0}' was rejected by the server: {1}")]
201 #[diagnostic(help("it may have expired; try again with a new code."))]
202 TfaCodeRejected(String, String),
203}
204
205impl From<SearchAndDownloadError> for UploadError {
206 fn from(source: SearchAndDownloadError) -> Self {
207 Self::SearchAndDownload(Box::new(source))
208 }
209}
210
211pub struct ApiKey(String);
212
213#[derive(Error, Debug, Diagnostic)]
214#[non_exhaustive]
215#[error("no API key provided")]
216#[diagnostic(help("please set the $LUX_API_KEY environment variable"))]
217pub struct ApiKeyUnspecified;
218
219impl ApiKey {
220 pub fn new() -> Result<Self, ApiKeyUnspecified> {
223 Ok(Self(
224 env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
225 ))
226 }
227
228 pub fn from(str: &str) -> Self {
238 Self(str.to_string())
239 }
240
241 pub unsafe fn get(&self) -> &str {
248 &self.0
249 }
250}
251
252struct TfaToken(String);
256
257impl TfaToken {
258 unsafe fn get(&self) -> &str {
265 &self.0
266 }
267}
268
269impl<'de> Deserialize<'de> for TfaToken {
270 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
271 where
272 D: serde::Deserializer<'de>,
273 {
274 String::deserialize(deserializer).map(Self)
275 }
276}
277
278#[derive(Deserialize)]
279struct LuarocksTfaVerificationSuccess {
280 tfa_token: TfaToken,
281}
282
283#[derive(Deserialize)]
285#[serde(untagged)]
286enum LuarocksTfaVerificationResponse {
287 Success(LuarocksTfaVerificationSuccess),
288 Failure(LuarocksErrorResponse),
289}
290
291#[derive(Deserialize)]
292struct LuarocksErrorResponse {
293 errors: Vec<String>,
294}
295
296#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
297#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
298#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
299#[serde(rename_all = "lowercase")]
300#[derive(Default)]
301#[cfg(not(feature = "gpgme"))]
302pub enum SignatureProtocol {
303 #[default]
304 None,
305}
306
307#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
308#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
309#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
310#[serde(rename_all = "lowercase")]
311#[derive(Default)]
312#[cfg(feature = "gpgme")]
313pub enum SignatureProtocol {
314 None,
315 Assuan,
316 CMS,
317 #[default]
318 Default,
319 G13,
320 GPGConf,
321 OpenPGP,
322 Spawn,
323 UIServer,
324}
325
326#[cfg(feature = "gpgme")]
327impl From<SignatureProtocol> for gpgme::Protocol {
328 fn from(val: SignatureProtocol) -> Self {
329 match val {
330 SignatureProtocol::Default => gpgme::Protocol::Default,
331 SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
332 SignatureProtocol::CMS => gpgme::Protocol::Cms,
333 SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
334 SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
335 SignatureProtocol::G13 => gpgme::Protocol::G13,
336 SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
337 SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
338 SignatureProtocol::None => unreachable!(),
339 }
340 }
341}
342
343#[tracing::instrument(
344 name = "Uploading",
345 level = "info",
346 skip_all,
347 fields(project = args.project.toml().package().to_string()),
348)]
349async fn upload_from_project(args: ProjectUpload<'_>) -> Result<(), UploadError> {
350 let project = args.project;
351 let api_key = args.api_key.unwrap_or(ApiKey::new()?);
352 let config = args.config;
353 let package_db = args.package_db;
354
355 let client = crate::reqwest::https_client(config)?;
356
357 helpers::ensure_tool_version(client, config.server()).await?;
358 helpers::ensure_user_exists(client, &api_key, config.server()).await?;
359
360 let (rockspec, rockspec_content) =
361 helpers::generate_rockspec(project, client, &api_key, config, package_db).await?;
362
363 #[cfg(feature = "gpgme")]
364 let protocol = args.sign_protocol;
365 #[cfg(not(feature = "gpgme"))]
366 let protocol = SignatureProtocol::None;
367
368 let signed = sign_rockspec(&rockspec_content, protocol)?;
369
370 upload_rockspec_content(
371 config,
372 client,
373 &api_key,
374 PreparedRockspec {
375 package: rockspec.package().clone(),
376 version: rockspec.version().clone(),
377 content: rockspec_content,
378 },
379 signed,
380 args.tfa_code,
381 )
382 .await
383}
384
385#[tracing::instrument(
386 name = "Uploading",
387 level = "info",
388 skip_all,
389 fields(package = args.rockspec.package().to_string()),
390)]
391async fn upload_from_rockspec(args: RockspecUpload<'_>) -> Result<(), UploadError> {
392 let rockspec = args.rockspec;
393 let api_key = args.api_key.unwrap_or(ApiKey::new()?);
394 #[cfg(feature = "gpgme")]
395 let protocol = args.sign_protocol;
396 #[cfg(not(feature = "gpgme"))]
397 let protocol = SignatureProtocol::None;
398 let config = args.config;
399
400 let client = crate::reqwest::https_client(config)?;
401
402 helpers::ensure_tool_version(client, config.server()).await?;
403 helpers::ensure_user_exists(client, &api_key, config.server()).await?;
404
405 if let PackageVersion::StringVer(ver) = rockspec.version() {
406 return Err(UploadError::UnsupportedVersion(ver.to_string()));
407 }
408
409 if helpers::rock_exists(
410 client,
411 &api_key,
412 rockspec.package(),
413 rockspec.version(),
414 config.server(),
415 )
416 .await?
417 {
418 return Err(UploadError::RockExists(config.server().clone()));
419 }
420
421 let rockspec_content = rockspec
422 .to_lua_remote_rockspec_string()
423 .expect("remote rockspec serialization is infallible");
424 let signed = sign_rockspec(&rockspec_content, protocol)?;
425
426 upload_rockspec_content(
427 config,
428 client,
429 &api_key,
430 PreparedRockspec {
431 package: rockspec.package().clone(),
432 version: rockspec.version().clone(),
433 content: rockspec_content,
434 },
435 signed,
436 args.tfa_code,
437 )
438 .await
439}
440
441struct PreparedRockspec {
442 package: PackageName,
443 version: PackageVersion,
444 content: String,
445}
446
447async fn upload_rockspec_content(
448 config: &Config,
449 client: &reqwest::Client,
450 api_key: &ApiKey,
451 rockspec: PreparedRockspec,
452 signed: Option<String>,
453 tfa_code: Option<String>,
454) -> Result<(), UploadError> {
455 let part = Part::text(rockspec.content)
456 .file_name(format!(
457 "{}-{}.rockspec",
458 rockspec.package, rockspec.version
459 ))
460 .mime_str("application/octet-stream")?;
461
462 let multipart = {
463 let multipart = Form::new().part("rockspec_file", part);
464
465 match signed {
466 Some(signature) => {
467 let part = Part::text(signature).file_name("project.rockspec.sig");
468 multipart.part("rockspec_sig", part)
469 }
470 None => multipart,
471 }
472 };
473
474 let mut request = client
475 .post(unsafe { helpers::url_for_method(config.server(), api_key, "upload")? })
476 .multipart(multipart);
477
478 if let Some(code) = tfa_code {
479 let token = helpers::verify_tfa_code(client, config.server(), api_key, &code).await?;
480 request = request.header(TFA_TOKEN_HEADER, unsafe { token.get() });
481 }
482
483 let response = request.send().await?;
484
485 let status = response.status();
486 if status.is_server_error() {
487 Err(UploadError::Server(config.server().clone(), status))
488 } else if status.is_success() {
489 Ok(())
490 } else {
491 let response = response.json::<LuarocksErrorResponse>().await?;
492 let errors = response.errors.into_iter().join("\n");
493 Err(UploadError::Client(config.server().clone(), errors))
494 }
495}
496
497#[cfg(feature = "gpgme")]
498fn sign_rockspec(
499 rockspec_content: &str,
500 protocol: SignatureProtocol,
501) -> Result<Option<String>, UploadError> {
502 if let SignatureProtocol::None = protocol {
503 return Ok(None);
504 }
505
506 let mut ctx = Context::from_protocol(protocol.into())?;
507 let mut signature = Data::new()?;
508
509 ctx.set_armor(true);
510 ctx.sign_detached(rockspec_content.to_owned(), &mut signature)?;
511
512 let mut signature_str = String::new();
513 signature.read_to_string(&mut signature_str)?;
514
515 Ok(Some(signature_str))
516}
517
518#[cfg(not(feature = "gpgme"))]
519fn sign_rockspec(
520 _rockspec_content: &str,
521 _protocol: SignatureProtocol,
522) -> Result<Option<String>, UploadError> {
523 Ok(None)
524}
525
526mod helpers {
527 use std::collections::HashMap;
528
529 use super::*;
530 use crate::hash::HasIntegrity;
531 use crate::operations::Download;
532 use crate::package::{PackageName, PackageSpec, PackageVersion};
533 use crate::project::project_toml::RemoteProjectToml;
534 use crate::upload::RockCheckError;
535 use crate::upload::{ToolCheckError, UserCheckError};
536 use itertools::Itertools;
537 use reqwest::Client;
538 use ssri::Integrity;
539 use url::Url;
540
541 pub(crate) unsafe fn url_for_method(
546 server_url: &Url,
547 api_key: &ApiKey,
548 endpoint: &str,
549 ) -> Result<Url, url::ParseError> {
550 server_url
551 .join("api/1/")?
552 .join(&format!("{}/", api_key.get()))?
553 .join(endpoint)
554 }
555
556 #[tracing::instrument(level = "trace", skip(client))]
557 pub(crate) async fn ensure_tool_version(
558 client: &Client,
559 server_url: &Url,
560 ) -> Result<(), ToolCheckError> {
561 let url = server_url.join("api/tool_version")?;
562 let response: VersionCheckResponse = client
563 .post(url)
564 .json(&("current", TOOL_VERSION))
565 .send()
566 .await?
567 .json()
568 .await?;
569
570 if response.version == TOOL_VERSION {
571 Ok(())
572 } else {
573 Err(ToolCheckError::ToolOutdated(
574 server_url.to_string(),
575 response,
576 ))
577 }
578 }
579
580 #[tracing::instrument(level = "trace", skip(client, api_key))]
581 pub(crate) async fn verify_tfa_code(
582 client: &Client,
583 server_url: &Url,
584 api_key: &ApiKey,
585 tfa_code: &str,
586 ) -> Result<TfaToken, UploadError> {
587 let response = client
588 .get(unsafe { url_for_method(server_url, api_key, "verify_tfa")? })
589 .query(&[("code", tfa_code)])
590 .send()
591 .await?;
592 let status = response.status();
593 if status.is_server_error() {
594 Err(UploadError::Server(server_url.clone(), status))
595 } else {
596 match response.json::<LuarocksTfaVerificationResponse>().await? {
597 LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess {
598 tfa_token,
599 }) => Ok(tfa_token),
600 LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { errors }) => {
601 Err(UploadError::TfaCodeRejected(
602 tfa_code.to_string(),
603 errors.into_iter().join("\n"),
604 ))
605 }
606 }
607 }
608 }
609
610 #[tracing::instrument(level = "trace", skip(client, api_key))]
611 pub(crate) async fn ensure_user_exists(
612 client: &Client,
613 api_key: &ApiKey,
614 server_url: &Url,
615 ) -> Result<(), UserCheckError> {
616 let response = client
617 .get(unsafe { url_for_method(server_url, api_key, "status")? })
618 .send()
619 .await?;
620 let status = response.status();
621 if status.is_client_error() {
622 Err(UserCheckError::UserNotFound)
623 } else if status.is_server_error() {
624 Err(UserCheckError::Server(server_url.clone(), status))
625 } else {
626 Ok(())
627 }
628 }
629
630 #[tracing::instrument(level = "trace", skip_all)]
631 pub(crate) async fn generate_rockspec(
632 project: &Project,
633 client: &Client,
634 api_key: &ApiKey,
635 config: &Config,
636 package_db: &PackageDB,
637 ) -> Result<(RemoteProjectToml, String), UploadError> {
638 for specrev in SpecRevIterator::new() {
639 let rockspec = project.toml().into_remote(Some(specrev))?;
640
641 let rockspec_content = rockspec.to_lua_remote_rockspec_string()?;
642
643 if let PackageVersion::StringVer(ver) = rockspec.version() {
644 return Err(UploadError::UnsupportedVersion(ver.to_string()));
645 }
646 if helpers::rock_exists(
647 client,
648 api_key,
649 rockspec.package(),
650 rockspec.version(),
651 config.server(),
652 )
653 .await?
654 {
655 let package =
656 PackageSpec::new(rockspec.package().clone(), rockspec.version().clone());
657 let existing_rockspec = Download::new(&package.into(), config)
658 .package_db(package_db)
659 .download_rockspec()
660 .await?
661 .rockspec;
662 let existing_rockspec_hash =
663 existing_rockspec.hash().await.map_err(UploadError::Hash)?;
664 let rockspec_content_hash = Integrity::from(&rockspec_content);
665 if existing_rockspec_hash
666 .matches(&rockspec_content_hash)
667 .is_some()
668 {
669 return Err(UploadError::RockExists(config.server().clone()));
670 }
671 } else {
672 return Ok((rockspec, rockspec_content));
673 }
674 }
675 Err(UploadError::MaxSpecRevsExceeded)
676 }
677
678 #[tracing::instrument(level = "trace", skip(client, api_key))]
679 pub(crate) async fn rock_exists(
680 client: &Client,
681 api_key: &ApiKey,
682 name: &PackageName,
683 version: &PackageVersion,
684 server: &Url,
685 ) -> Result<bool, RockCheckError> {
686 let server_response_raw_json = client
687 .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
688 .query(&(
689 ("package", name.to_string()),
690 ("version", version.to_string()),
691 ))
692 .send()
693 .await?
694 .error_for_status()?
695 .text()
696 .await?;
697 let response_map: Option<HashMap<String, serde_json::Value>> =
698 serde_json::from_str(&server_response_raw_json).ok();
699 Ok(response_map.is_some_and(|response_map| {
700 response_map.contains_key("module") && response_map.contains_key("version")
701 }))
702 }
703}
704
705#[cfg(test)]
706mod test {
707 use super::*;
708
709 use crate::config::ConfigBuilder;
710 use crate::lua_version::LuaVersion;
711 use assert_fs::prelude::*;
712 use httptest::matchers::{contains, request};
713 use httptest::responders::{json_encoded, status_code, Responder};
714 use httptest::{Expectation, Server};
715 use serial_test::serial;
716
717 #[test]
718 fn test_deserialize_tfa_success() {
719 let response_str = r#"{
720 "success": true,
721 "expires": 1782939987,
722 "tfa_token": "dummy_token"
723}
724"#;
725 let result = serde_json::from_str(response_str).unwrap();
726 assert!(matches!(
727 result,
728 LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess { .. })
729 ));
730 }
731
732 #[test]
733 fn test_deserialize_tfa_failure() {
734 let response_str = r#"{
735 "errors": [
736 "Invalid verification code"
737 ]
738}
739"#;
740 let result = serde_json::from_str(response_str).unwrap();
741 assert!(matches!(
742 result,
743 LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { .. })
744 ));
745 }
746
747 #[test]
748 fn test_deserialize_luarocks_error_response() {
749 let response_str = r#"{
750 "errors": [
751 "Invalid verification code"
752 ]
753}
754"#;
755 let result = serde_json::from_str(response_str).unwrap();
756 assert!(matches!(result, LuarocksErrorResponse { .. }));
757 }
758
759 const API_KEY: &str = "test-api-key";
760 const TOOL_VERSION_PATH: &str = "/api/tool_version";
761 const STATUS_PATH: &str = "/api/1/test-api-key/status";
762 const CHECK_ROCKSPEC_PATH: &str = "/api/1/test-api-key/check_rockspec";
763 const VERIFY_TFA_PATH: &str = "/api/1/test-api-key/verify_tfa";
764 const UPLOAD_PATH: &str = "/api/1/test-api-key/upload";
765
766 async fn ready(
767 server: &Server,
768 ) -> (
769 assert_fs::TempDir,
770 assert_fs::TempDir,
771 Project,
772 Config,
773 PackageDB,
774 ) {
775 std::env::set_var("LUX_API_KEY", API_KEY);
776 let project_dir = assert_fs::TempDir::new().unwrap();
777 project_dir
778 .child("lux.toml")
779 .write_str(
780 r#"
781package = "foo"
782version = "1.0.0"
783lua = ">=5.1"
784
785[source]
786url = "https://example.com/foo.zip"
787
788[build]
789type = "builtin"
790"#,
791 )
792 .unwrap();
793 let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
794
795 let config_dir = assert_fs::TempDir::new().unwrap();
796 let config = ConfigBuilder::default()
797 .server(Some(Url::parse(&server.url_str("/")).unwrap()))
798 .data_dir(Some(config_dir.path().to_path_buf()))
799 .cache_dir(Some(config_dir.path().to_path_buf()))
800 .no_manifest_cache(Some(true))
801 .lua_version(Some(LuaVersion::Lua51))
802 .build()
803 .unwrap();
804 let package_db = PackageDB::from_config(&config).await.unwrap();
805
806 (project_dir, config_dir, project, config, package_db)
807 }
808
809 fn expect_tool_version(server: &Server, version: &str) {
810 server.expect(
811 Expectation::matching(request::path(TOOL_VERSION_PATH))
812 .respond_with(json_encoded(serde_json::json!({ "version": version }))),
813 );
814 }
815
816 fn expect_status(server: &Server, status: u16) {
817 server.expect(
818 Expectation::matching(request::path(STATUS_PATH)).respond_with(status_code(status)),
819 );
820 }
821
822 fn expect_rock_missing(server: &Server) {
823 server.expect(
824 Expectation::matching(request::path(CHECK_ROCKSPEC_PATH))
825 .respond_with(json_encoded(serde_json::json!({}))),
826 );
827 }
828
829 fn expect_upload(server: &Server, responder: impl Responder + 'static) {
830 server.expect(Expectation::matching(request::path(UPLOAD_PATH)).respond_with(responder));
831 }
832
833 async fn upload(
834 project: &Project,
835 config: &Config,
836 package_db: &PackageDB,
837 tfa_code: Option<String>,
838 ) -> Result<(), UploadError> {
839 let upload = ProjectUpload::new()
840 .project(project)
841 .config(config)
842 .api_key(ApiKey::from(API_KEY))
843 .maybe_tfa_code(tfa_code)
844 .package_db(package_db);
845 #[cfg(feature = "gpgme")]
846 let upload = upload.sign_protocol(SignatureProtocol::None);
847 upload.upload_to_luarocks().await
848 }
849
850 #[tokio::test]
851 #[serial]
852 async fn test_upload_to_mock_server() {
853 let server = Server::run();
854 expect_tool_version(&server, TOOL_VERSION);
855 expect_status(&server, 200);
856 expect_rock_missing(&server);
857 expect_upload(&server, status_code(200));
858
859 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
860 upload(&project, &config, &package_db, None).await.unwrap();
861 }
862
863 #[tokio::test]
864 #[serial]
865 async fn test_upload_forwards_tfa_token_header() {
866 let server = Server::run();
867 expect_tool_version(&server, TOOL_VERSION);
868 expect_status(&server, 200);
869 expect_rock_missing(&server);
870 server.expect(
871 Expectation::matching(request::path(VERIFY_TFA_PATH)).respond_with(json_encoded(
872 serde_json::json!({ "success": true, "tfa_token": "tfa-token-123" }),
873 )),
874 );
875 server.expect(
876 Expectation::matching(request::headers(contains(("x-tfa-token", "tfa-token-123"))))
877 .respond_with(status_code(200)),
878 );
879
880 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
881 upload(&project, &config, &package_db, Some("123456".into()))
882 .await
883 .unwrap();
884 }
885
886 #[tokio::test]
887 #[serial]
888 async fn test_upload_rejected_tfa_code() {
889 let server = Server::run();
890 expect_tool_version(&server, TOOL_VERSION);
891 expect_status(&server, 200);
892 expect_rock_missing(&server);
893 server.expect(
894 Expectation::matching(request::path(VERIFY_TFA_PATH)).respond_with(json_encoded(
895 serde_json::json!({ "errors": ["Invalid verification code"] }),
896 )),
897 );
898
899 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
900 let err = upload(&project, &config, &package_db, Some("000000".into()))
901 .await
902 .unwrap_err();
903 assert!(matches!(err, UploadError::TfaCodeRejected(..)));
904 }
905
906 #[tokio::test]
907 #[serial]
908 async fn test_upload_server_error() {
909 let server = Server::run();
910 expect_tool_version(&server, TOOL_VERSION);
911 expect_status(&server, 200);
912 expect_rock_missing(&server);
913 expect_upload(&server, status_code(500));
914
915 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
916 let err = upload(&project, &config, &package_db, None)
917 .await
918 .unwrap_err();
919 assert!(matches!(
920 err,
921 UploadError::Server(_, StatusCode::INTERNAL_SERVER_ERROR)
922 ));
923 }
924
925 #[tokio::test]
926 #[serial]
927 async fn test_upload_client_error() {
928 let server = Server::run();
929 expect_tool_version(&server, TOOL_VERSION);
930 expect_status(&server, 200);
931 expect_rock_missing(&server);
932 expect_upload(
933 &server,
934 status_code(400)
935 .append_header("Content-Type", "application/json")
936 .body(r#"{"errors":["invalid rockspec"]}"#),
937 );
938
939 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
940 let err = upload(&project, &config, &package_db, None)
941 .await
942 .unwrap_err();
943 match err {
944 UploadError::Client(_, message) => assert_eq!(message, "invalid rockspec"),
945 other => panic!("unexpected error: {other:?}"),
946 }
947 }
948
949 #[tokio::test]
950 #[serial]
951 async fn test_upload_outdated_tool_version() {
952 let server = Server::run();
953 expect_tool_version(&server, "0.0.1");
954
955 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
956 let err = upload(&project, &config, &package_db, None)
957 .await
958 .unwrap_err();
959 assert!(matches!(
960 err,
961 UploadError::ToolCheck(ToolCheckError::ToolOutdated(..))
962 ));
963 }
964
965 #[tokio::test]
966 #[serial]
967 async fn test_upload_unknown_user() {
968 let server = Server::run();
969 expect_tool_version(&server, TOOL_VERSION);
970 expect_status(&server, 401);
971
972 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
973 let err = upload(&project, &config, &package_db, None)
974 .await
975 .unwrap_err();
976 assert!(matches!(
977 err,
978 UploadError::UserCheck(UserCheckError::UserNotFound)
979 ));
980 }
981}