Skip to main content

lux_lib/upload/
mod.rs

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/// Whether to perform the upload or only report what would be uploaded.
31#[derive(Clone, Copy, Debug, Default)]
32pub enum Mode {
33    #[default]
34    Upload,
35    DryRun,
36}
37
38/// A rocks package uploader, providing fine-grained control
39/// over how a package should be uploaded.
40#[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    /// Upload a package to a luarocks server.
59    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/// An uploader for an existing `.rockspec` file.
66#[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    /// Upload a rockspec to a luarocks server.
84    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    /// Retrieves the rocks API key from the `$LUX_API_KEY` environment
234    /// variable and seals it in this struct.
235    pub fn new() -> Result<Self, ApiKeyUnspecified> {
236        Ok(Self(
237            env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
238        ))
239    }
240
241    /// Creates an API key from a [`String`].
242    ///
243    /// # Safety
244    ///
245    /// This struct is designed to be sealed without a [`Display`](std::fmt::Display) implementation
246    /// so that it can never accidentally be printed.
247    ///
248    /// Ensure that you do not do anything else with the API key string prior to sealing it in this
249    /// struct.
250    pub fn from(str: &str) -> Self {
251        Self(str.to_string())
252    }
253
254    /// Retrieves the underlying API key as a [`String`].
255    ///
256    /// # Safety
257    ///
258    /// Strings may accidentally be printed as part of its [`Display`](std::fmt::Display)
259    /// implementation. Ensure that you never pass this variable somewhere it may be displayed.
260    pub unsafe fn get(&self) -> &str {
261        &self.0
262    }
263}
264
265/// 2FA token.
266/// This struct is designed to be sealed without a [`Display`](std::fmt::Display) implementation
267/// so that it can never accidentally be printed.
268struct TfaToken(String);
269
270impl TfaToken {
271    /// Retrieves the underlying 2FA token as a [`String`].
272    ///
273    /// # Safety
274    ///
275    /// Strings may accidentally be printed as part of its [`Display`](std::fmt::Display)
276    /// implementation. Ensure that you never pass this variable somewhere it may be displayed.
277    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/// Models the response received from luarocks.org
297#[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/// The rockspec that was or would have been uploaded.
457#[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    /// WARNING: This function is unsafe,
582    /// because it adds the unmasked API key to the URL.
583    /// When using URLs created by this function,
584    /// pay attention not to leak the API key in errors.
585    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}