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;
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 =
658 operations::download_exact_rockspec(&package, package_db, config)
659 .await?
660 .rockspec;
661 let existing_rockspec_hash =
662 existing_rockspec.hash().await.map_err(UploadError::Hash)?;
663 if existing_rockspec_hash
664 .matches(&Integrity::from(&rockspec_content))
665 .is_some()
666 {
667 return Err(UploadError::RockExists(config.server().clone()));
668 }
669 } else {
670 return Ok((rockspec, rockspec_content));
671 }
672 }
673 Err(UploadError::MaxSpecRevsExceeded)
674 }
675
676 pub(crate) async fn rock_exists(
677 client: &Client,
678 api_key: &ApiKey,
679 name: &PackageName,
680 version: &PackageVersion,
681 server: &Url,
682 ) -> Result<bool, RockCheckError> {
683 let server_response_raw_json = client
684 .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
685 .query(&(
686 ("package", name.to_string()),
687 ("version", version.to_string()),
688 ))
689 .send()
690 .await?
691 .error_for_status()?
692 .text()
693 .await?;
694 Ok(rock_exists_in_response(&server_response_raw_json))
695 }
696
697 pub(super) fn rock_exists_in_response(response: &str) -> bool {
698 serde_json::from_str::<HashMap<String, serde_json::Value>>(response).is_ok_and(|response| {
699 let present = |key| response.get(key).is_some_and(|value| !value.is_null());
700 present("module") && present("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 #[test]
760 fn test_rock_exists_in_response() {
761 for (response, expected) in [
762 ("{}", false),
763 (r#"{"module":null,"version":null}"#, false),
764 (r#"{"module":{},"version":null}"#, false),
765 (r#"{"module":null,"version":{}}"#, false),
766 (r#"{"module":{},"version":{}}"#, true),
767 ] {
768 assert_eq!(helpers::rock_exists_in_response(response), expected);
769 }
770 }
771
772 const API_KEY: &str = "test-api-key";
773 const TOOL_VERSION_PATH: &str = "/api/tool_version";
774 const STATUS_PATH: &str = "/api/1/test-api-key/status";
775 const CHECK_ROCKSPEC_PATH: &str = "/api/1/test-api-key/check_rockspec";
776 const VERIFY_TFA_PATH: &str = "/api/1/test-api-key/verify_tfa";
777 const UPLOAD_PATH: &str = "/api/1/test-api-key/upload";
778
779 async fn ready(
780 server: &Server,
781 ) -> (
782 assert_fs::TempDir,
783 assert_fs::TempDir,
784 Project,
785 Config,
786 PackageDB,
787 ) {
788 std::env::set_var("LUX_API_KEY", API_KEY);
789 let project_dir = assert_fs::TempDir::new().unwrap();
790 project_dir
791 .child("lux.toml")
792 .write_str(
793 r#"
794package = "foo"
795version = "1.0.0"
796lua = ">=5.1"
797
798[source]
799url = "https://example.com/foo.zip"
800
801[build]
802type = "builtin"
803"#,
804 )
805 .unwrap();
806 let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
807
808 let config_dir = assert_fs::TempDir::new().unwrap();
809 let config = ConfigBuilder::default()
810 .server(Some(Url::parse(&server.url_str("/")).unwrap()))
811 .data_dir(Some(config_dir.path().to_path_buf()))
812 .cache_dir(Some(config_dir.path().to_path_buf()))
813 .no_manifest_cache(Some(true))
814 .lua_version(Some(LuaVersion::Lua51))
815 .build()
816 .unwrap();
817 let package_db = PackageDB::from_config(&config).await.unwrap();
818
819 (project_dir, config_dir, project, config, package_db)
820 }
821
822 fn expect_tool_version(server: &Server, version: &str) {
823 server.expect(
824 Expectation::matching(request::path(TOOL_VERSION_PATH))
825 .respond_with(json_encoded(serde_json::json!({ "version": version }))),
826 );
827 }
828
829 fn expect_status(server: &Server, status: u16) {
830 server.expect(
831 Expectation::matching(request::path(STATUS_PATH)).respond_with(status_code(status)),
832 );
833 }
834
835 fn expect_rock_missing(server: &Server) {
836 server.expect(
837 Expectation::matching(request::path(CHECK_ROCKSPEC_PATH))
838 .respond_with(json_encoded(serde_json::json!({}))),
839 );
840 }
841
842 fn expect_upload(server: &Server, responder: impl Responder + 'static) {
843 server.expect(Expectation::matching(request::path(UPLOAD_PATH)).respond_with(responder));
844 }
845
846 async fn upload(
847 project: &Project,
848 config: &Config,
849 package_db: &PackageDB,
850 tfa_code: Option<String>,
851 ) -> Result<(), UploadError> {
852 let upload = ProjectUpload::new()
853 .project(project)
854 .config(config)
855 .api_key(ApiKey::from(API_KEY))
856 .maybe_tfa_code(tfa_code)
857 .package_db(package_db);
858 #[cfg(feature = "gpgme")]
859 let upload = upload.sign_protocol(SignatureProtocol::None);
860 upload.upload_to_luarocks().await
861 }
862
863 #[tokio::test]
864 #[serial]
865 async fn test_upload_to_mock_server() {
866 let server = Server::run();
867 expect_tool_version(&server, TOOL_VERSION);
868 expect_status(&server, 200);
869 expect_rock_missing(&server);
870 expect_upload(&server, status_code(200));
871
872 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
873 upload(&project, &config, &package_db, None).await.unwrap();
874 }
875
876 #[tokio::test]
877 #[serial]
878 async fn test_generate_rockspec_auto_specrev_increment() {
879 let server = Server::run();
880 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
881
882 for revision in 1..3 {
883 server.expect(
884 Expectation::matching(httptest::matchers::all_of![
885 request::path(CHECK_ROCKSPEC_PATH),
886 request::query(httptest::matchers::url_decoded(contains((
887 "version",
888 format!("1.0.0-{revision}"),
889 )))),
890 ])
891 .respond_with(json_encoded(serde_json::json!({
892 "module": {},
893 "version": {},
894 }))),
895 );
896 }
897 server.expect(
898 Expectation::matching(httptest::matchers::all_of![
899 request::path(CHECK_ROCKSPEC_PATH),
900 request::query(httptest::matchers::url_decoded(contains((
901 "version", "1.0.0-3",
902 )))),
903 ])
904 .respond_with(json_encoded(serde_json::json!({
905 "module": null,
906 "version": null,
907 }))),
908 );
909 server.expect(
910 Expectation::matching(request::path("/manifest-5.1.zip"))
911 .respond_with(status_code(404)),
912 );
913 server.expect(
914 Expectation::matching(request::path("/manifest-5.1")).respond_with(
915 status_code(200)
916 .body(r#"repository = { foo = { ["1.0.0-2"] = { { arch = "rockspec" } } } }"#),
917 ),
918 );
919 for revision in 1u16..=2 {
920 let content = project
921 .toml()
922 .into_remote(Some(revision.into()))
923 .unwrap()
924 .to_lua_remote_rockspec_string()
925 .unwrap()
926 .replace("https://example.com/foo.zip", "https://example.com/old.zip");
927 server.expect(
928 Expectation::matching(request::path(format!("/foo-1.0.0-{revision}.rockspec")))
929 .respond_with(status_code(200).body(content)),
930 );
931 }
932
933 let (rockspec, _) = helpers::generate_rockspec(
934 &project,
935 &reqwest::Client::new(),
936 &ApiKey::from(API_KEY),
937 &config,
938 &package_db,
939 )
940 .await
941 .unwrap();
942
943 assert_eq!(rockspec.version().to_string(), "1.0.0-3");
944 }
945
946 #[tokio::test]
947 #[serial]
948 async fn test_upload_forwards_tfa_token_header() {
949 let server = Server::run();
950 expect_tool_version(&server, TOOL_VERSION);
951 expect_status(&server, 200);
952 expect_rock_missing(&server);
953 server.expect(
954 Expectation::matching(request::path(VERIFY_TFA_PATH)).respond_with(json_encoded(
955 serde_json::json!({ "success": true, "tfa_token": "tfa-token-123" }),
956 )),
957 );
958 server.expect(
959 Expectation::matching(request::headers(contains(("x-tfa-token", "tfa-token-123"))))
960 .respond_with(status_code(200)),
961 );
962
963 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
964 upload(&project, &config, &package_db, Some("123456".into()))
965 .await
966 .unwrap();
967 }
968
969 #[tokio::test]
970 #[serial]
971 async fn test_upload_rejected_tfa_code() {
972 let server = Server::run();
973 expect_tool_version(&server, TOOL_VERSION);
974 expect_status(&server, 200);
975 expect_rock_missing(&server);
976 server.expect(
977 Expectation::matching(request::path(VERIFY_TFA_PATH)).respond_with(json_encoded(
978 serde_json::json!({ "errors": ["Invalid verification code"] }),
979 )),
980 );
981
982 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
983 let err = upload(&project, &config, &package_db, Some("000000".into()))
984 .await
985 .unwrap_err();
986 assert!(matches!(err, UploadError::TfaCodeRejected(..)));
987 }
988
989 #[tokio::test]
990 #[serial]
991 async fn test_upload_server_error() {
992 let server = Server::run();
993 expect_tool_version(&server, TOOL_VERSION);
994 expect_status(&server, 200);
995 expect_rock_missing(&server);
996 expect_upload(&server, status_code(500));
997
998 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
999 let err = upload(&project, &config, &package_db, None)
1000 .await
1001 .unwrap_err();
1002 assert!(matches!(
1003 err,
1004 UploadError::Server(_, StatusCode::INTERNAL_SERVER_ERROR)
1005 ));
1006 }
1007
1008 #[tokio::test]
1009 #[serial]
1010 async fn test_upload_client_error() {
1011 let server = Server::run();
1012 expect_tool_version(&server, TOOL_VERSION);
1013 expect_status(&server, 200);
1014 expect_rock_missing(&server);
1015 expect_upload(
1016 &server,
1017 status_code(400)
1018 .append_header("Content-Type", "application/json")
1019 .body(r#"{"errors":["invalid rockspec"]}"#),
1020 );
1021
1022 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
1023 let err = upload(&project, &config, &package_db, None)
1024 .await
1025 .unwrap_err();
1026 match err {
1027 UploadError::Client(_, message) => assert_eq!(message, "invalid rockspec"),
1028 other => panic!("unexpected error: {other:?}"),
1029 }
1030 }
1031
1032 #[tokio::test]
1033 #[serial]
1034 async fn test_upload_outdated_tool_version() {
1035 let server = Server::run();
1036 expect_tool_version(&server, "0.0.1");
1037
1038 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
1039 let err = upload(&project, &config, &package_db, None)
1040 .await
1041 .unwrap_err();
1042 assert!(matches!(
1043 err,
1044 UploadError::ToolCheck(ToolCheckError::ToolOutdated(..))
1045 ));
1046 }
1047
1048 #[tokio::test]
1049 #[serial]
1050 async fn test_upload_unknown_user() {
1051 let server = Server::run();
1052 expect_tool_version(&server, TOOL_VERSION);
1053 expect_status(&server, 401);
1054
1055 let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
1056 let err = upload(&project, &config, &package_db, None)
1057 .await
1058 .unwrap_err();
1059 assert!(matches!(
1060 err,
1061 UploadError::UserCheck(UserCheckError::UserNotFound)
1062 ));
1063 }
1064}