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::Download;
532    use crate::package::{PackageName, PackageSpec, PackageVersion};
533    use crate::project::project_toml::RemoteProjectToml;
534    use crate::upload::RockCheckError;
535    use crate::upload::{ToolCheckError, UserCheckError};
536    use itertools::Itertools;
537    use reqwest::Client;
538    use ssri::Integrity;
539    use url::Url;
540
541    /// 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 = Download::new(&package.into(), config)
658                    .package_db(package_db)
659                    .download_rockspec()
660                    .await?
661                    .rockspec;
662                let existing_rockspec_hash =
663                    existing_rockspec.hash().await.map_err(UploadError::Hash)?;
664                let rockspec_content_hash = Integrity::from(&rockspec_content);
665                if existing_rockspec_hash
666                    .matches(&rockspec_content_hash)
667                    .is_some()
668                {
669                    return Err(UploadError::RockExists(config.server().clone()));
670                }
671            } else {
672                return Ok((rockspec, rockspec_content));
673            }
674        }
675        Err(UploadError::MaxSpecRevsExceeded)
676    }
677
678    #[tracing::instrument(level = "trace", skip(client, api_key))]
679    pub(crate) async fn rock_exists(
680        client: &Client,
681        api_key: &ApiKey,
682        name: &PackageName,
683        version: &PackageVersion,
684        server: &Url,
685    ) -> Result<bool, RockCheckError> {
686        let server_response_raw_json = client
687            .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
688            .query(&(
689                ("package", name.to_string()),
690                ("version", version.to_string()),
691            ))
692            .send()
693            .await?
694            .error_for_status()?
695            .text()
696            .await?;
697        let response_map: Option<HashMap<String, serde_json::Value>> =
698            serde_json::from_str(&server_response_raw_json).ok();
699        Ok(response_map.is_some_and(|response_map| {
700            response_map.contains_key("module") && response_map.contains_key("version")
701        }))
702    }
703}
704
705#[cfg(test)]
706mod test {
707    use super::*;
708
709    use crate::config::ConfigBuilder;
710    use crate::lua_version::LuaVersion;
711    use assert_fs::prelude::*;
712    use httptest::matchers::{contains, request};
713    use httptest::responders::{json_encoded, status_code, Responder};
714    use httptest::{Expectation, Server};
715    use serial_test::serial;
716
717    #[test]
718    fn test_deserialize_tfa_success() {
719        let response_str = r#"{
720    "success": true,
721    "expires": 1782939987,
722    "tfa_token": "dummy_token"
723}
724"#;
725        let result = serde_json::from_str(response_str).unwrap();
726        assert!(matches!(
727            result,
728            LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess { .. })
729        ));
730    }
731
732    #[test]
733    fn test_deserialize_tfa_failure() {
734        let response_str = r#"{
735    "errors": [
736        "Invalid verification code"
737    ]
738}
739"#;
740        let result = serde_json::from_str(response_str).unwrap();
741        assert!(matches!(
742            result,
743            LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { .. })
744        ));
745    }
746
747    #[test]
748    fn test_deserialize_luarocks_error_response() {
749        let response_str = r#"{
750    "errors": [
751        "Invalid verification code"
752    ]
753}
754"#;
755        let result = serde_json::from_str(response_str).unwrap();
756        assert!(matches!(result, LuarocksErrorResponse { .. }));
757    }
758
759    const API_KEY: &str = "test-api-key";
760    const TOOL_VERSION_PATH: &str = "/api/tool_version";
761    const STATUS_PATH: &str = "/api/1/test-api-key/status";
762    const CHECK_ROCKSPEC_PATH: &str = "/api/1/test-api-key/check_rockspec";
763    const VERIFY_TFA_PATH: &str = "/api/1/test-api-key/verify_tfa";
764    const UPLOAD_PATH: &str = "/api/1/test-api-key/upload";
765
766    async fn ready(
767        server: &Server,
768    ) -> (
769        assert_fs::TempDir,
770        assert_fs::TempDir,
771        Project,
772        Config,
773        PackageDB,
774    ) {
775        std::env::set_var("LUX_API_KEY", API_KEY);
776        let project_dir = assert_fs::TempDir::new().unwrap();
777        project_dir
778            .child("lux.toml")
779            .write_str(
780                r#"
781package = "foo"
782version = "1.0.0"
783lua = ">=5.1"
784
785[source]
786url = "https://example.com/foo.zip"
787
788[build]
789type = "builtin"
790"#,
791            )
792            .unwrap();
793        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
794
795        let config_dir = assert_fs::TempDir::new().unwrap();
796        let config = ConfigBuilder::default()
797            .server(Some(Url::parse(&server.url_str("/")).unwrap()))
798            .data_dir(Some(config_dir.path().to_path_buf()))
799            .cache_dir(Some(config_dir.path().to_path_buf()))
800            .no_manifest_cache(Some(true))
801            .lua_version(Some(LuaVersion::Lua51))
802            .build()
803            .unwrap();
804        let package_db = PackageDB::from_config(&config).await.unwrap();
805
806        (project_dir, config_dir, project, config, package_db)
807    }
808
809    fn expect_tool_version(server: &Server, version: &str) {
810        server.expect(
811            Expectation::matching(request::path(TOOL_VERSION_PATH))
812                .respond_with(json_encoded(serde_json::json!({ "version": version }))),
813        );
814    }
815
816    fn expect_status(server: &Server, status: u16) {
817        server.expect(
818            Expectation::matching(request::path(STATUS_PATH)).respond_with(status_code(status)),
819        );
820    }
821
822    fn expect_rock_missing(server: &Server) {
823        server.expect(
824            Expectation::matching(request::path(CHECK_ROCKSPEC_PATH))
825                .respond_with(json_encoded(serde_json::json!({}))),
826        );
827    }
828
829    fn expect_upload(server: &Server, responder: impl Responder + 'static) {
830        server.expect(Expectation::matching(request::path(UPLOAD_PATH)).respond_with(responder));
831    }
832
833    async fn upload(
834        project: &Project,
835        config: &Config,
836        package_db: &PackageDB,
837        tfa_code: Option<String>,
838    ) -> Result<(), UploadError> {
839        let upload = ProjectUpload::new()
840            .project(project)
841            .config(config)
842            .api_key(ApiKey::from(API_KEY))
843            .maybe_tfa_code(tfa_code)
844            .package_db(package_db);
845        #[cfg(feature = "gpgme")]
846        let upload = upload.sign_protocol(SignatureProtocol::None);
847        upload.upload_to_luarocks().await
848    }
849
850    #[tokio::test]
851    #[serial]
852    async fn test_upload_to_mock_server() {
853        let server = Server::run();
854        expect_tool_version(&server, TOOL_VERSION);
855        expect_status(&server, 200);
856        expect_rock_missing(&server);
857        expect_upload(&server, status_code(200));
858
859        let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
860        upload(&project, &config, &package_db, None).await.unwrap();
861    }
862
863    #[tokio::test]
864    #[serial]
865    async fn test_upload_forwards_tfa_token_header() {
866        let server = Server::run();
867        expect_tool_version(&server, TOOL_VERSION);
868        expect_status(&server, 200);
869        expect_rock_missing(&server);
870        server.expect(
871            Expectation::matching(request::path(VERIFY_TFA_PATH)).respond_with(json_encoded(
872                serde_json::json!({ "success": true, "tfa_token": "tfa-token-123" }),
873            )),
874        );
875        server.expect(
876            Expectation::matching(request::headers(contains(("x-tfa-token", "tfa-token-123"))))
877                .respond_with(status_code(200)),
878        );
879
880        let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
881        upload(&project, &config, &package_db, Some("123456".into()))
882            .await
883            .unwrap();
884    }
885
886    #[tokio::test]
887    #[serial]
888    async fn test_upload_rejected_tfa_code() {
889        let server = Server::run();
890        expect_tool_version(&server, TOOL_VERSION);
891        expect_status(&server, 200);
892        expect_rock_missing(&server);
893        server.expect(
894            Expectation::matching(request::path(VERIFY_TFA_PATH)).respond_with(json_encoded(
895                serde_json::json!({ "errors": ["Invalid verification code"] }),
896            )),
897        );
898
899        let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
900        let err = upload(&project, &config, &package_db, Some("000000".into()))
901            .await
902            .unwrap_err();
903        assert!(matches!(err, UploadError::TfaCodeRejected(..)));
904    }
905
906    #[tokio::test]
907    #[serial]
908    async fn test_upload_server_error() {
909        let server = Server::run();
910        expect_tool_version(&server, TOOL_VERSION);
911        expect_status(&server, 200);
912        expect_rock_missing(&server);
913        expect_upload(&server, status_code(500));
914
915        let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
916        let err = upload(&project, &config, &package_db, None)
917            .await
918            .unwrap_err();
919        assert!(matches!(
920            err,
921            UploadError::Server(_, StatusCode::INTERNAL_SERVER_ERROR)
922        ));
923    }
924
925    #[tokio::test]
926    #[serial]
927    async fn test_upload_client_error() {
928        let server = Server::run();
929        expect_tool_version(&server, TOOL_VERSION);
930        expect_status(&server, 200);
931        expect_rock_missing(&server);
932        expect_upload(
933            &server,
934            status_code(400)
935                .append_header("Content-Type", "application/json")
936                .body(r#"{"errors":["invalid rockspec"]}"#),
937        );
938
939        let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
940        let err = upload(&project, &config, &package_db, None)
941            .await
942            .unwrap_err();
943        match err {
944            UploadError::Client(_, message) => assert_eq!(message, "invalid rockspec"),
945            other => panic!("unexpected error: {other:?}"),
946        }
947    }
948
949    #[tokio::test]
950    #[serial]
951    async fn test_upload_outdated_tool_version() {
952        let server = Server::run();
953        expect_tool_version(&server, "0.0.1");
954
955        let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
956        let err = upload(&project, &config, &package_db, None)
957            .await
958            .unwrap_err();
959        assert!(matches!(
960            err,
961            UploadError::ToolCheck(ToolCheckError::ToolOutdated(..))
962        ));
963    }
964
965    #[tokio::test]
966    #[serial]
967    async fn test_upload_unknown_user() {
968        let server = Server::run();
969        expect_tool_version(&server, TOOL_VERSION);
970        expect_status(&server, 401);
971
972        let (_project_dir, _config_dir, project, config, package_db) = ready(&server).await;
973        let err = upload(&project, &config, &package_db, None)
974            .await
975            .unwrap_err();
976        assert!(matches!(
977            err,
978            UploadError::UserCheck(UserCheckError::UserNotFound)
979        ));
980    }
981}