Skip to main content

lux_lib/upload/
mod.rs

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/// A rocks package uploader, providing fine-grained control
30/// over how a package should be uploaded.
31#[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    /// Upload a package to a luarocks server.
48    pub async fn upload_to_luarocks(self) -> Result<(), UploadError> {
49        let args = self._build();
50        upload_from_project(args).await
51    }
52}
53
54/// An uploader for an existing `.rockspec` file.
55#[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    /// Upload a rockspec to a luarocks server.
71    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    /// Retrieves the rocks API key from the `$LUX_API_KEY` environment
221    /// variable and seals it in this struct.
222    pub fn new() -> Result<Self, ApiKeyUnspecified> {
223        Ok(Self(
224            env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
225        ))
226    }
227
228    /// Creates an API key from a [`String`].
229    ///
230    /// # Safety
231    ///
232    /// This struct is designed to be sealed without a [`Display`](std::fmt::Display) implementation
233    /// so that it can never accidentally be printed.
234    ///
235    /// Ensure that you do not do anything else with the API key string prior to sealing it in this
236    /// struct.
237    pub fn from(str: &str) -> Self {
238        Self(str.to_string())
239    }
240
241    /// Retrieves the underlying API key as a [`String`].
242    ///
243    /// # Safety
244    ///
245    /// Strings may accidentally be printed as part of its [`Display`](std::fmt::Display)
246    /// implementation. Ensure that you never pass this variable somewhere it may be displayed.
247    pub unsafe fn get(&self) -> &str {
248        &self.0
249    }
250}
251
252/// 2FA token.
253/// This struct is designed to be sealed without a [`Display`](std::fmt::Display) implementation
254/// so that it can never accidentally be printed.
255struct TfaToken(String);
256
257impl TfaToken {
258    /// Retrieves the underlying 2FA token as a [`String`].
259    ///
260    /// # Safety
261    ///
262    /// Strings may accidentally be printed as part of its [`Display`](std::fmt::Display)
263    /// implementation. Ensure that you never pass this variable somewhere it may be displayed.
264    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/// Models the response received from luarocks.org
284#[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    /// WARNING: This function is unsafe,
542    /// because it adds the unmasked API key to the URL.
543    /// When using URLs created by this function,
544    /// pay attention not to leak the API key in errors.
545    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}