Skip to main content

lux_lib/upload/
mod.rs

1use std::{env, io};
2
3use crate::operations::SearchAndDownloadError;
4use crate::package::SpecRevIterator;
5use crate::project::project_toml::RemoteProjectTomlValidationError;
6use crate::remote_package_db::RemotePackageDB;
7use crate::rockspec::Rockspec;
8use crate::TOOL_VERSION;
9use crate::{config::Config, project::Project};
10
11use bon::Builder;
12use itertools::Itertools;
13use miette::Diagnostic;
14use reqwest::multipart::{Form, Part};
15use reqwest::StatusCode;
16use serde::Deserialize;
17use serde_enum_str::Serialize_enum_str;
18use thiserror::Error;
19use tracing::span;
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 RemotePackageDB,
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#[derive(Deserialize, Debug)]
55pub struct VersionCheckResponse {
56    version: String,
57}
58
59#[derive(Error, Debug, Diagnostic)]
60#[non_exhaustive]
61pub enum ToolCheckError {
62    #[error("error parsing tool check URL:\n{0}")]
63    #[diagnostic(help("check your server configuration"))]
64    ParseError(#[from] url::ParseError),
65    #[error("error sending HTTP request")]
66    #[diagnostic(help(
67        r#"check your network connection and server configuration.
68if the issue persists, the server may be temporarily unavailable."#
69    ))]
70    Request(#[from] reqwest::Error),
71    #[error(r#"Lux is out of date with {0}'s expected tool version.
72    Lux is at version {TOOL_VERSION}, server is at {server_version}"#, server_version = _1.version)]
73    #[diagnostic(help("ensure you have an up-to-date version of Lux installed"))]
74    ToolOutdated(String, VersionCheckResponse),
75}
76
77#[derive(Error, Debug, Diagnostic)]
78#[non_exhaustive]
79pub enum UserCheckError {
80    #[error("error parsing user check URL")]
81    #[diagnostic(help("check your server configuration"))]
82    Parse(#[from] url::ParseError),
83    #[error(transparent)]
84    #[diagnostic(help(
85        r#"check your network connection and server configuration.
86if the issue persists, the server may be temporarily unavailable."#
87    ))]
88    Request(#[from] reqwest::Error),
89    #[diagnostic(help(
90        "ensure that the API key provided via the $LUX_API_KEY environemnt variable is correct"
91    ))]
92    #[error("invalid API key provided")]
93    UserNotFound,
94    #[error("server '{0}' responded with error status: '{1}'")]
95    #[diagnostic(help("the server may be temporarily unavailable"))]
96    Server(Url, StatusCode),
97}
98
99#[derive(Error, Debug, Diagnostic)]
100#[non_exhaustive]
101pub enum RockCheckError {
102    #[error("parse error while checking rock status on server")]
103    #[diagnostic(help("check your server configuration"))]
104    ParseError(#[from] url::ParseError),
105    #[error("HTTP request error while checking rock status on server")]
106    #[diagnostic(help(
107        r#"check your network connection and server configuration.
108if the issue persists, the server may be temporarily unavailable."#
109    ))]
110    Request(#[from] reqwest::Error),
111}
112
113#[derive(Error, Debug, Diagnostic)]
114#[non_exhaustive]
115#[error(transparent)]
116pub enum UploadError {
117    #[error("error parsing upload URL")]
118    #[diagnostic(help("check your server configuration"))]
119    ParseError(#[from] url::ParseError),
120    #[error("HTPP request error while uploading")]
121    #[diagnostic(help(
122        r#"check your network connection and server configuration.
123if the issue persists, the server may be temporarily unavailable."#
124    ))]
125    Request(#[from] reqwest::Error),
126    #[error("server '{0}' responded with error status: '{1}'")]
127    #[diagnostic(help("the server may be temporarily unavailable"))]
128    Server(Url, StatusCode),
129    #[error("client error when requesting {0}:\n{1}")]
130    #[diagnostic(help(
131        r#"check your network connection and server configuration.
132if the issue persists, the server may be temporarily unavailable."#
133    ))]
134    Client(Url, String),
135    #[diagnostic(transparent)]
136    RockCheck(#[from] RockCheckError),
137    #[error("a package with the same rockspec content already exists on the server: '{0}'")]
138    #[diagnostic(help("try uploading a rockspec with a different version"))]
139    RockExists(Url),
140    #[error("unable to read rockspec")]
141    #[diagnostic(help("check that the rockspec exists and is readable"))]
142    RockspecRead(#[from] std::io::Error),
143    #[cfg(feature = "gpgme")]
144    #[error("GPG signing failed")]
145    #[diagnostic(help(
146        r#"ensure that a GPG agent is running and that a valid GPG signing key is registered.
147          if you'd like to skip the signing step, supply `--sign-protocol=none`
148        "#
149    ))]
150    Signature(#[from] gpgme::Error),
151    #[error(transparent)]
152    #[diagnostic(transparent)]
153    ToolCheck(#[from] ToolCheckError),
154    #[error(transparent)]
155    #[diagnostic(transparent)]
156    UserCheck(#[from] UserCheckError),
157    #[error(transparent)]
158    #[diagnostic(transparent)]
159    ApiKeyUnspecified(#[from] ApiKeyUnspecified),
160    #[error(transparent)]
161    #[diagnostic(transparent)]
162    ValidationError(#[from] RemoteProjectTomlValidationError),
163    #[error("unsupported version: '{0}'")]
164    #[diagnostic(help("Lux can upload packages with a SemVer version, 'dev' or 'scm'"))]
165    UnsupportedVersion(String),
166    #[error("{0}")] // We don't know the concrete error type
167    #[diagnostic(help("check the rockspec or lux.toml for valid syntax and make sure it matches the specification."),)]
168    Rockspec(String),
169    #[error("the maximum supported number of rockspec revisions per version has been exceeded")]
170    #[diagnostic(help("bump the version to a version that has not yet been published"))]
171    MaxSpecRevsExceeded,
172    #[error("rock already exists on server. Error downloading existing rockspec")]
173    #[diagnostic(forward(0))]
174    SearchAndDownload(#[from] SearchAndDownloadError),
175    #[error("error computing rockspec hash")]
176    Hash(io::Error),
177    #[error("the 2FA code '{0}' was rejected by the server: {1}")]
178    #[diagnostic(help("it may have expired; try again with a new code."))]
179    TfaCodeRejected(String, String),
180}
181
182pub struct ApiKey(String);
183
184#[derive(Error, Debug, Diagnostic)]
185#[non_exhaustive]
186#[error("no API key provided")]
187#[diagnostic(help("please set the $LUX_API_KEY environment variable"))]
188pub struct ApiKeyUnspecified;
189
190impl ApiKey {
191    /// Retrieves the rocks API key from the `$LUX_API_KEY` environment
192    /// variable and seals it in this struct.
193    pub fn new() -> Result<Self, ApiKeyUnspecified> {
194        Ok(Self(
195            env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
196        ))
197    }
198
199    /// Creates an API key from a [`String`].
200    ///
201    /// # Safety
202    ///
203    /// This struct is designed to be sealed without a [`Display`](std::fmt::Display) implementation
204    /// so that it can never accidentally be printed.
205    ///
206    /// Ensure that you do not do anything else with the API key string prior to sealing it in this
207    /// struct.
208    pub fn from(str: &str) -> Self {
209        Self(str.to_string())
210    }
211
212    /// Retrieves the underlying API key as a [`String`].
213    ///
214    /// # Safety
215    ///
216    /// Strings may accidentally be printed as part of its [`Display`](std::fmt::Display)
217    /// implementation. Ensure that you never pass this variable somewhere it may be displayed.
218    pub unsafe fn get(&self) -> &str {
219        &self.0
220    }
221}
222
223/// 2FA token.
224/// This struct is designed to be sealed without a [`Display`](std::fmt::Display) implementation
225/// so that it can never accidentally be printed.
226struct TfaToken(String);
227
228impl TfaToken {
229    /// Retrieves the underlying 2FA token as a [`String`].
230    ///
231    /// # Safety
232    ///
233    /// Strings may accidentally be printed as part of its [`Display`](std::fmt::Display)
234    /// implementation. Ensure that you never pass this variable somewhere it may be displayed.
235    unsafe fn get(&self) -> &str {
236        &self.0
237    }
238}
239
240impl<'de> Deserialize<'de> for TfaToken {
241    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
242    where
243        D: serde::Deserializer<'de>,
244    {
245        String::deserialize(deserializer).map(Self)
246    }
247}
248
249#[derive(Deserialize)]
250struct LuarocksTfaVerificationSuccess {
251    tfa_token: TfaToken,
252}
253
254/// Models the response received from luarocks.org
255#[derive(Deserialize)]
256#[serde(untagged)]
257enum LuarocksTfaVerificationResponse {
258    Success(LuarocksTfaVerificationSuccess),
259    Failure(LuarocksErrorResponse),
260}
261
262#[derive(Deserialize)]
263struct LuarocksErrorResponse {
264    errors: Vec<String>,
265}
266
267#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
268#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
269#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
270#[serde(rename_all = "lowercase")]
271#[derive(Default)]
272#[cfg(not(feature = "gpgme"))]
273pub enum SignatureProtocol {
274    #[default]
275    None,
276}
277
278#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
279#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
280#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
281#[serde(rename_all = "lowercase")]
282#[derive(Default)]
283#[cfg(feature = "gpgme")]
284pub enum SignatureProtocol {
285    None,
286    Assuan,
287    CMS,
288    #[default]
289    Default,
290    G13,
291    GPGConf,
292    OpenPGP,
293    Spawn,
294    UIServer,
295}
296
297#[cfg(feature = "gpgme")]
298impl From<SignatureProtocol> for gpgme::Protocol {
299    fn from(val: SignatureProtocol) -> Self {
300        match val {
301            SignatureProtocol::Default => gpgme::Protocol::Default,
302            SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
303            SignatureProtocol::CMS => gpgme::Protocol::Cms,
304            SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
305            SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
306            SignatureProtocol::G13 => gpgme::Protocol::G13,
307            SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
308            SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
309            SignatureProtocol::None => unreachable!(),
310        }
311    }
312}
313
314#[tracing::instrument(level = "trace", skip_all)]
315async fn upload_from_project(args: ProjectUpload<'_>) -> Result<(), UploadError> {
316    let project = args.project;
317    let api_key = args.api_key.unwrap_or(ApiKey::new()?);
318    #[cfg(feature = "gpgme")]
319    let protocol = args.sign_protocol;
320    let config = args.config;
321    let package_db = args.package_db;
322    let span = span!(
323        tracing::Level::INFO,
324        "Uploading",
325        package = project.toml().package().to_string(),
326    );
327    let _enter = span.enter();
328
329    let client = crate::reqwest::new_https_client(args.config)?;
330
331    helpers::ensure_tool_version(&client, config.server()).await?;
332    helpers::ensure_user_exists(&client, &api_key, config.server()).await?;
333
334    let (rockspec, rockspec_content) =
335        helpers::generate_rockspec(project, &client, &api_key, config, package_db).await?;
336
337    #[cfg(not(feature = "gpgme"))]
338    let signed: Option<String> = None;
339
340    #[cfg(feature = "gpgme")]
341    let signed = if let SignatureProtocol::None = protocol {
342        None
343    } else {
344        let mut ctx = Context::from_protocol(protocol.into())?;
345        let mut signature = Data::new()?;
346
347        ctx.set_armor(true);
348        ctx.sign_detached(rockspec_content.clone(), &mut signature)?;
349
350        let mut signature_str = String::new();
351        signature.read_to_string(&mut signature_str)?;
352
353        Some(signature_str)
354    };
355
356    let rockspec = Part::text(rockspec_content)
357        .file_name(format!(
358            "{}-{}.rockspec",
359            rockspec.package(),
360            rockspec.version()
361        ))
362        .mime_str("application/octet-stream")?;
363
364    let multipart = {
365        let multipart = Form::new().part("rockspec_file", rockspec);
366
367        match signed {
368            Some(signature) => {
369                let part = Part::text(signature).file_name("project.rockspec.sig");
370                multipart.part("rockspec_sig", part)
371            }
372            None => multipart,
373        }
374    };
375
376    let mut request = client
377        .post(unsafe { helpers::url_for_method(config.server(), &api_key, "upload")? })
378        .multipart(multipart);
379
380    if let Some(code) = args.tfa_code {
381        let token = helpers::verify_tfa_code(&client, config.server(), &api_key, &code).await?;
382        request = request.header(TFA_TOKEN_HEADER, unsafe { token.get() });
383    }
384
385    let response = request.send().await?;
386
387    let status = response.status();
388    if status.is_server_error() {
389        Err(UploadError::Server(config.server().clone(), status))
390    } else if status.is_success() {
391        Ok(())
392    } else {
393        let response = response.json::<LuarocksErrorResponse>().await?;
394        let errors = response.errors.into_iter().join("\n");
395        Err(UploadError::Client(config.server().clone(), errors))
396    }
397}
398
399mod helpers {
400    use std::collections::HashMap;
401
402    use super::*;
403    use crate::hash::HasIntegrity;
404    use crate::operations::Download;
405    use crate::package::{PackageName, PackageSpec, PackageVersion};
406    use crate::project::project_toml::RemoteProjectToml;
407    use crate::upload::RockCheckError;
408    use crate::upload::{ToolCheckError, UserCheckError};
409    use itertools::Itertools;
410    use reqwest::Client;
411    use ssri::Integrity;
412    use url::Url;
413
414    /// WARNING: This function is unsafe,
415    /// because it adds the unmasked API key to the URL.
416    /// When using URLs created by this function,
417    /// pay attention not to leak the API key in errors.
418    pub(crate) unsafe fn url_for_method(
419        server_url: &Url,
420        api_key: &ApiKey,
421        endpoint: &str,
422    ) -> Result<Url, url::ParseError> {
423        server_url
424            .join("api/1/")?
425            .join(&format!("{}/", api_key.get()))?
426            .join(endpoint)
427    }
428
429    #[tracing::instrument(level = "trace", skip(client))]
430    pub(crate) async fn ensure_tool_version(
431        client: &Client,
432        server_url: &Url,
433    ) -> Result<(), ToolCheckError> {
434        let url = server_url.join("api/tool_version")?;
435        let response: VersionCheckResponse = client
436            .post(url)
437            .json(&("current", TOOL_VERSION))
438            .send()
439            .await?
440            .json()
441            .await?;
442
443        if response.version == TOOL_VERSION {
444            Ok(())
445        } else {
446            Err(ToolCheckError::ToolOutdated(
447                server_url.to_string(),
448                response,
449            ))
450        }
451    }
452
453    #[tracing::instrument(level = "trace", skip(client, api_key))]
454    pub(crate) async fn verify_tfa_code(
455        client: &Client,
456        server_url: &Url,
457        api_key: &ApiKey,
458        tfa_code: &str,
459    ) -> Result<TfaToken, UploadError> {
460        let response = client
461            .get(unsafe { url_for_method(server_url, api_key, "verify_tfa")? })
462            .query(&("code", tfa_code.to_string()))
463            .send()
464            .await?;
465        let status = response.status();
466        if status.is_server_error() {
467            Err(UploadError::Server(server_url.clone(), status))
468        } else {
469            match response.json::<LuarocksTfaVerificationResponse>().await? {
470                LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess {
471                    tfa_token,
472                }) => Ok(tfa_token),
473                LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { errors }) => {
474                    Err(UploadError::TfaCodeRejected(
475                        tfa_code.to_string(),
476                        errors.into_iter().join("\n"),
477                    ))
478                }
479            }
480        }
481    }
482
483    #[tracing::instrument(level = "trace", skip(client, api_key))]
484    pub(crate) async fn ensure_user_exists(
485        client: &Client,
486        api_key: &ApiKey,
487        server_url: &Url,
488    ) -> Result<(), UserCheckError> {
489        let response = client
490            .get(unsafe { url_for_method(server_url, api_key, "status")? })
491            .send()
492            .await?;
493        let status = response.status();
494        if status.is_client_error() {
495            Err(UserCheckError::UserNotFound)
496        } else if status.is_server_error() {
497            Err(UserCheckError::Server(server_url.clone(), status))
498        } else {
499            Ok(())
500        }
501    }
502
503    #[tracing::instrument(level = "trace", skip_all)]
504    pub(crate) async fn generate_rockspec(
505        project: &Project,
506        client: &Client,
507        api_key: &ApiKey,
508        config: &Config,
509        package_db: &RemotePackageDB,
510    ) -> Result<(RemoteProjectToml, String), UploadError> {
511        for specrev in SpecRevIterator::new() {
512            let rockspec = project.toml().into_remote(Some(specrev))?;
513
514            let rockspec_content = rockspec
515                .to_lua_remote_rockspec_string()
516                .map_err(|err| UploadError::Rockspec(err.to_string()))?;
517
518            if let PackageVersion::StringVer(ver) = rockspec.version() {
519                return Err(UploadError::UnsupportedVersion(ver.to_string()));
520            }
521            if helpers::rock_exists(
522                client,
523                api_key,
524                rockspec.package(),
525                rockspec.version(),
526                config.server(),
527            )
528            .await?
529            {
530                let package =
531                    PackageSpec::new(rockspec.package().clone(), rockspec.version().clone());
532                let existing_rockspec = Download::new(&package.into(), config)
533                    .package_db(package_db)
534                    .download_rockspec()
535                    .await?
536                    .rockspec;
537                let existing_rockspec_hash = existing_rockspec.hash().map_err(UploadError::Hash)?;
538                let rockspec_content_hash = Integrity::from(&rockspec_content);
539                if existing_rockspec_hash
540                    .matches(&rockspec_content_hash)
541                    .is_some()
542                {
543                    return Err(UploadError::RockExists(config.server().clone()));
544                }
545            } else {
546                return Ok((rockspec, rockspec_content));
547            }
548        }
549        Err(UploadError::MaxSpecRevsExceeded)
550    }
551
552    #[tracing::instrument(level = "trace", skip(client, api_key))]
553    async fn rock_exists(
554        client: &Client,
555        api_key: &ApiKey,
556        name: &PackageName,
557        version: &PackageVersion,
558        server: &Url,
559    ) -> Result<bool, RockCheckError> {
560        let server_response_raw_json = client
561            .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
562            .query(&(
563                ("package", name.to_string()),
564                ("version", version.to_string()),
565            ))
566            .send()
567            .await?
568            .error_for_status()?
569            .text()
570            .await?;
571        let response_map: Option<HashMap<String, serde_json::Value>> =
572            serde_json::from_str(&server_response_raw_json).ok();
573        Ok(response_map.is_some_and(|response_map| {
574            response_map.contains_key("module") && response_map.contains_key("version")
575        }))
576    }
577}
578
579#[cfg(test)]
580mod test {
581    use super::*;
582
583    #[test]
584    fn test_deserialize_tfa_success() {
585        let response_str = r#"{
586    "success": true,
587    "expires": 1782939987,
588    "tfa_token": "dummy_token"
589}
590"#;
591        let result = serde_json::from_str(response_str).unwrap();
592        assert!(matches!(
593            result,
594            LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess { .. })
595        ));
596    }
597
598    #[test]
599    fn test_deserialize_tfa_failure() {
600        let response_str = r#"{
601    "errors": [
602        "Invalid verification code"
603    ]
604}
605"#;
606        let result = serde_json::from_str(response_str).unwrap();
607        assert!(matches!(
608            result,
609            LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { .. })
610        ));
611    }
612
613    #[test]
614    fn test_deserialize_luarocks_error_response() {
615        let response_str = r#"{
616    "errors": [
617        "Invalid verification code"
618    ]
619}
620"#;
621        let result = serde_json::from_str(response_str).unwrap();
622        assert!(matches!(result, LuarocksErrorResponse { .. }));
623    }
624}