1use crate::package;
2use std::{env, io};
3
4use crate::lua_rockspec::RemoteLuaRockspec;
5use crate::operations::SearchAndDownloadError;
6use crate::package::SpecRevIterator;
7use crate::package_db::PackageDB;
8use crate::project::project_toml::{self, RemoteProjectTomlValidationError};
9use crate::rockspec::Rockspec;
10use crate::TOOL_VERSION;
11use crate::{config::Config, project::Project};
12
13use bon::Builder;
14use itertools::Itertools;
15use miette::Diagnostic;
16use reqwest::multipart::{Form, Part};
17use reqwest::StatusCode;
18use serde::Deserialize;
19use serde_enum_str::Serialize_enum_str;
20use thiserror::Error;
21use url::Url;
22
23#[cfg(feature = "gpgme")]
24use gpgme::{Context, Data};
25#[cfg(feature = "gpgme")]
26use std::io::Read;
27
28const TFA_TOKEN_HEADER: &str = "X-TFA-Token";
29
30#[derive(Clone, Copy, Debug, Default)]
32pub enum Mode {
33 #[default]
34 Upload,
35 DryRun,
36}
37
38#[derive(Builder)]
41#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
42pub struct ProjectUpload<'a> {
43 project: &'a Project,
44 api_key: Option<ApiKey>,
45 tfa_code: Option<String>,
46 #[cfg(feature = "gpgme")]
47 sign_protocol: SignatureProtocol,
48 config: &'a Config,
49 package_db: &'a PackageDB,
50 #[builder(default)]
51 mode: Mode,
52}
53
54impl<State> ProjectUploadBuilder<'_, State>
55where
56 State: project_upload_builder::State + project_upload_builder::IsComplete,
57{
58 pub async fn upload_to_luarocks(self) -> Result<PreparedRockspec, Error> {
60 let args = self._build();
61 upload_from_project(args).await
62 }
63}
64
65#[derive(Builder)]
67#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
68pub struct RockspecUpload<'a> {
69 rockspec: &'a RemoteLuaRockspec,
70 api_key: Option<ApiKey>,
71 tfa_code: Option<String>,
72 #[cfg(feature = "gpgme")]
73 sign_protocol: SignatureProtocol,
74 config: &'a Config,
75 #[builder(default)]
76 mode: Mode,
77}
78
79impl<State> RockspecUploadBuilder<'_, State>
80where
81 State: rockspec_upload_builder::State + rockspec_upload_builder::IsComplete,
82{
83 pub async fn upload_to_luarocks(self) -> Result<PreparedRockspec, Error> {
85 let args = self._build();
86 upload_from_rockspec(args).await
87 }
88}
89
90#[derive(Deserialize, Debug)]
91pub struct VersionCheckResponse {
92 version: String,
93}
94
95#[derive(Error, Debug, Diagnostic)]
96#[non_exhaustive]
97pub enum ToolCheckError {
98 #[error("error parsing tool check URL")]
99 #[diagnostic(help("check your server configuration"))]
100 ParseError(#[from] url::ParseError),
101 #[error("error sending HTTP request")]
102 #[diagnostic(help(
103 r#"check your network connection and server configuration.
104if the issue persists, the server may be temporarily unavailable."#
105 ))]
106 Request(#[from] reqwest::Error),
107 #[error(r#"Lux is out of date with {0}'s expected tool version.
108 Lux is at version {TOOL_VERSION}, server is at {server_version}"#, server_version = _1.version)]
109 #[diagnostic(help("ensure you have an up-to-date version of Lux installed"))]
110 ToolOutdated(String, VersionCheckResponse),
111}
112
113#[derive(Error, Debug, Diagnostic)]
114#[non_exhaustive]
115pub enum UserCheckError {
116 #[error("error parsing user check URL")]
117 #[diagnostic(help("check your server configuration"))]
118 Parse(#[from] url::ParseError),
119 #[error(transparent)]
120 #[diagnostic(help(
121 r#"check your network connection and server configuration.
122if the issue persists, the server may be temporarily unavailable."#
123 ))]
124 Request(#[from] reqwest::Error),
125 #[diagnostic(help(
126 "ensure that the API key provided via the $LUX_API_KEY environemnt variable is correct"
127 ))]
128 #[error("invalid API key provided")]
129 UserNotFound,
130 #[error("server '{0}' responded with error status: '{1}'")]
131 #[diagnostic(help("the server may be temporarily unavailable"))]
132 Server(Url, StatusCode),
133}
134
135#[derive(Error, Debug, Diagnostic)]
136#[non_exhaustive]
137pub enum RockCheckError {
138 #[error("parse error while checking rock status on server")]
139 #[diagnostic(help("check your server configuration"))]
140 ParseError(#[from] url::ParseError),
141 #[error("HTTP request error while checking rock status on server")]
142 #[diagnostic(help(
143 r#"check your network connection and server configuration.
144if the issue persists, the server may be temporarily unavailable."#
145 ))]
146 Request(#[from] reqwest::Error),
147}
148
149#[derive(Error, Debug, Diagnostic)]
150#[non_exhaustive]
151#[error(transparent)]
152pub enum Error {
153 #[error("error parsing upload URL")]
154 #[diagnostic(help("check your server configuration"))]
155 ParseError(#[from] url::ParseError),
156 #[error("HTPP request error while uploading")]
157 #[diagnostic(help(
158 r#"check your network connection and server configuration.
159if the issue persists, the server may be temporarily unavailable."#
160 ))]
161 Request(#[from] reqwest::Error),
162 #[error("server '{0}' responded with error status: '{1}'")]
163 #[diagnostic(help("the server may be temporarily unavailable"))]
164 Server(Url, StatusCode),
165 #[error("client error when requesting {0}:\n{1}")]
166 #[diagnostic(help(
167 r#"check your network connection and server configuration.
168if the issue persists, the server may be temporarily unavailable."#
169 ))]
170 Client(Url, String),
171 #[diagnostic(transparent)]
172 RockCheck(#[from] RockCheckError),
173 #[error("a package with the same rockspec content already exists on the server: '{0}'")]
174 #[diagnostic(help("try uploading a rockspec with a different version"))]
175 RockExists(Url),
176 #[error("unable to read rockspec")]
177 #[diagnostic(help("check that the rockspec exists and is readable"))]
178 RockspecRead(#[from] std::io::Error),
179 #[cfg(feature = "gpgme")]
180 #[error("GPG signing failed")]
181 #[diagnostic(help(
182 r#"ensure that a GPG agent is running and that a valid GPG signing key is registered.
183 if you'd like to skip the signing step, supply `--sign-protocol=none`
184 "#
185 ))]
186 Signature(#[from] gpgme::Error),
187 #[error(transparent)]
188 #[diagnostic(transparent)]
189 ToolCheck(#[from] ToolCheckError),
190 #[error(transparent)]
191 #[diagnostic(transparent)]
192 UserCheck(#[from] UserCheckError),
193 #[error(transparent)]
194 #[diagnostic(transparent)]
195 ApiKeyUnspecified(#[from] ApiKeyUnspecified),
196 #[error(transparent)]
197 #[diagnostic(transparent)]
198 ValidationError(#[from] RemoteProjectTomlValidationError),
199 #[error("unsupported version: '{0}'")]
200 #[diagnostic(help("Lux can upload packages with a SemVer version, 'dev' or 'scm'"))]
201 UnsupportedVersion(String),
202 #[error(transparent)]
203 #[diagnostic(transparent)]
204 Rockspec(#[from] project_toml::Error),
205 #[error("the maximum supported number of rockspec revisions per version has been exceeded")]
206 #[diagnostic(help("bump the version to a version that has not yet been published"))]
207 MaxSpecRevsExceeded,
208 #[error("rock already exists on server. Error downloading existing rockspec")]
209 #[diagnostic(forward(0))]
210 SearchAndDownload(#[from] Box<SearchAndDownloadError>),
211 #[error("error computing rockspec hash")]
212 Hash(io::Error),
213 #[error("the 2FA code '{0}' was rejected by the server: {1}")]
214 #[diagnostic(help("it may have expired; try again with a new code."))]
215 TfaCodeRejected(String, String),
216}
217
218impl From<SearchAndDownloadError> for Error {
219 fn from(source: SearchAndDownloadError) -> Self {
220 Self::SearchAndDownload(Box::new(source))
221 }
222}
223
224pub struct ApiKey(String);
225
226#[derive(Error, Debug, Diagnostic)]
227#[non_exhaustive]
228#[error("no API key provided")]
229#[diagnostic(help("please set the $LUX_API_KEY environment variable"))]
230pub struct ApiKeyUnspecified;
231
232impl ApiKey {
233 pub fn new() -> Result<Self, ApiKeyUnspecified> {
236 Ok(Self(
237 env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
238 ))
239 }
240
241 pub fn from(str: &str) -> Self {
251 Self(str.to_string())
252 }
253
254 pub unsafe fn get(&self) -> &str {
261 &self.0
262 }
263}
264
265struct TfaToken(String);
269
270impl TfaToken {
271 unsafe fn get(&self) -> &str {
278 &self.0
279 }
280}
281
282impl<'de> Deserialize<'de> for TfaToken {
283 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
284 where
285 D: serde::Deserializer<'de>,
286 {
287 String::deserialize(deserializer).map(Self)
288 }
289}
290
291#[derive(Deserialize)]
292struct LuarocksTfaVerificationSuccess {
293 tfa_token: TfaToken,
294}
295
296#[derive(Deserialize)]
298#[serde(untagged)]
299enum LuarocksTfaVerificationResponse {
300 Success(LuarocksTfaVerificationSuccess),
301 Failure(LuarocksErrorResponse),
302}
303
304#[derive(Deserialize)]
305struct LuarocksErrorResponse {
306 errors: Vec<String>,
307}
308
309#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
310#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
311#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
312#[serde(rename_all = "lowercase")]
313#[derive(Default)]
314#[cfg(not(feature = "gpgme"))]
315pub enum SignatureProtocol {
316 #[default]
317 None,
318}
319
320#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
321#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
322#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
323#[serde(rename_all = "lowercase")]
324#[derive(Default)]
325#[cfg(feature = "gpgme")]
326pub enum SignatureProtocol {
327 None,
328 Assuan,
329 CMS,
330 #[default]
331 Default,
332 G13,
333 GPGConf,
334 OpenPGP,
335 Spawn,
336 UIServer,
337}
338
339#[cfg(feature = "gpgme")]
340impl From<SignatureProtocol> for gpgme::Protocol {
341 fn from(val: SignatureProtocol) -> Self {
342 match val {
343 SignatureProtocol::Default => gpgme::Protocol::Default,
344 SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
345 SignatureProtocol::CMS => gpgme::Protocol::Cms,
346 SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
347 SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
348 SignatureProtocol::G13 => gpgme::Protocol::G13,
349 SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
350 SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
351 SignatureProtocol::None => unreachable!(),
352 }
353 }
354}
355
356#[tracing::instrument(
357 name = "Uploading",
358 level = "info",
359 skip_all,
360 fields(project = args.project.toml().package().to_string()),
361)]
362async fn upload_from_project(args: ProjectUpload<'_>) -> Result<PreparedRockspec, Error> {
363 let project = args.project;
364 let api_key = args.api_key.unwrap_or(ApiKey::new()?);
365 let config = args.config;
366 let package_db = args.package_db;
367
368 let client = crate::reqwest::https_client(config)?;
369
370 helpers::ensure_tool_version(client, config.server()).await?;
371 helpers::ensure_user_exists(client, &api_key, config.server()).await?;
372
373 let (rockspec, rockspec_content) =
374 helpers::generate_rockspec(project, client, &api_key, config, package_db).await?;
375
376 #[cfg(feature = "gpgme")]
377 let protocol = args.sign_protocol;
378 #[cfg(not(feature = "gpgme"))]
379 let protocol = SignatureProtocol::None;
380
381 let signed = sign_rockspec(&rockspec_content, protocol)?;
382
383 upload_rockspec_content(
384 config,
385 client,
386 &api_key,
387 PreparedRockspec {
388 package: rockspec.package().clone(),
389 version: rockspec.version().clone(),
390 content: rockspec_content,
391 },
392 signed,
393 args.tfa_code,
394 args.mode,
395 )
396 .await
397}
398
399#[tracing::instrument(
400 name = "Uploading",
401 level = "info",
402 skip_all,
403 fields(package = args.rockspec.package().to_string()),
404)]
405async fn upload_from_rockspec(args: RockspecUpload<'_>) -> Result<PreparedRockspec, Error> {
406 let rockspec = args.rockspec;
407 let api_key = args.api_key.unwrap_or(ApiKey::new()?);
408 #[cfg(feature = "gpgme")]
409 let protocol = args.sign_protocol;
410 #[cfg(not(feature = "gpgme"))]
411 let protocol = SignatureProtocol::None;
412 let config = args.config;
413
414 let client = crate::reqwest::https_client(config)?;
415
416 helpers::ensure_tool_version(client, config.server()).await?;
417 helpers::ensure_user_exists(client, &api_key, config.server()).await?;
418
419 if let package::Version::StringVer(ver) = rockspec.version() {
420 return Err(Error::UnsupportedVersion(ver.to_string()));
421 }
422
423 if helpers::rock_exists(
424 client,
425 &api_key,
426 rockspec.package(),
427 rockspec.version(),
428 config.server(),
429 )
430 .await?
431 {
432 return Err(Error::RockExists(config.server().clone()));
433 }
434
435 let rockspec_content = rockspec
436 .to_lua_remote_rockspec_string()
437 .expect("remote rockspec serialization is infallible");
438 let signed = sign_rockspec(&rockspec_content, protocol)?;
439
440 upload_rockspec_content(
441 config,
442 client,
443 &api_key,
444 PreparedRockspec {
445 package: rockspec.package().clone(),
446 version: rockspec.version().clone(),
447 content: rockspec_content,
448 },
449 signed,
450 args.tfa_code,
451 args.mode,
452 )
453 .await
454}
455
456#[derive(Debug)]
458pub struct PreparedRockspec {
459 pub package: package::Name,
460 pub version: package::Version,
461 pub content: String,
462}
463
464async fn upload_rockspec_content(
465 config: &Config,
466 client: &reqwest::Client,
467 api_key: &ApiKey,
468 rockspec: PreparedRockspec,
469 signed: Option<String>,
470 tfa_code: Option<String>,
471 mode: Mode,
472) -> Result<PreparedRockspec, Error> {
473 let request =
474 prepare_upload_request(config, client, api_key, &rockspec, signed, tfa_code).await?;
475 if matches!(mode, Mode::Upload) {
476 send_upload_request(config, request).await?;
477 }
478 Ok(rockspec)
479}
480
481async fn prepare_upload_request(
482 config: &Config,
483 client: &reqwest::Client,
484 api_key: &ApiKey,
485 rockspec: &PreparedRockspec,
486 signed: Option<String>,
487 tfa_code: Option<String>,
488) -> Result<reqwest::RequestBuilder, Error> {
489 let part = Part::text(rockspec.content.clone())
490 .file_name(format!(
491 "{}-{}.rockspec",
492 rockspec.package, rockspec.version
493 ))
494 .mime_str("application/octet-stream")?;
495
496 let multipart = {
497 let multipart = Form::new().part("rockspec_file", part);
498
499 match signed {
500 Some(signature) => {
501 let part = Part::text(signature).file_name("project.rockspec.sig");
502 multipart.part("rockspec_sig", part)
503 }
504 None => multipart,
505 }
506 };
507
508 let mut request = client
509 .post(unsafe { helpers::url_for_method(config.server(), api_key, "upload")? })
510 .multipart(multipart);
511
512 if let Some(code) = tfa_code {
513 let token = helpers::verify_tfa_code(client, config.server(), api_key, &code).await?;
514 request = request.header(TFA_TOKEN_HEADER, unsafe { token.get() });
515 }
516
517 Ok(request)
518}
519
520async fn send_upload_request(
521 config: &Config,
522 request: reqwest::RequestBuilder,
523) -> Result<(), Error> {
524 let response = request.send().await?;
525
526 let status = response.status();
527 if status.is_server_error() {
528 Err(Error::Server(config.server().clone(), status))
529 } else if status.is_success() {
530 Ok(())
531 } else {
532 let response = response.json::<LuarocksErrorResponse>().await?;
533 let errors = response.errors.into_iter().join("\n");
534 Err(Error::Client(config.server().clone(), errors))
535 }
536}
537
538#[cfg(feature = "gpgme")]
539fn sign_rockspec(
540 rockspec_content: &str,
541 protocol: SignatureProtocol,
542) -> Result<Option<String>, Error> {
543 if let SignatureProtocol::None = protocol {
544 return Ok(None);
545 }
546
547 let mut ctx = Context::from_protocol(protocol.into())?;
548 let mut signature = Data::new()?;
549
550 ctx.set_armor(true);
551 ctx.sign_detached(rockspec_content.to_owned(), &mut signature)?;
552
553 let mut signature_str = String::new();
554 signature.read_to_string(&mut signature_str)?;
555
556 Ok(Some(signature_str))
557}
558
559#[cfg(not(feature = "gpgme"))]
560fn sign_rockspec(
561 _rockspec_content: &str,
562 _protocol: SignatureProtocol,
563) -> Result<Option<String>, Error> {
564 Ok(None)
565}
566
567mod helpers {
568 use std::collections::HashMap;
569
570 use super::*;
571 use crate::hash::HasIntegrity;
572 use crate::operations;
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 package::Version::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 package::Spec::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: &package::Name,
716 version: &package::Version,
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}