Skip to main content

lux_lib/upload/
mod.rs

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