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