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