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 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 RemotePackageDB,
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:\n{0}")]
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("{0}")] // We don't know the concrete error type
166    #[diagnostic(help("check the rockspec or lux.toml for valid syntax and make sure it matches the specification."),)]
167    Rockspec(String),
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] 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
181pub struct ApiKey(String);
182
183#[derive(Error, Debug, Diagnostic)]
184#[non_exhaustive]
185#[error("no API key provided")]
186#[diagnostic(help("please set the $LUX_API_KEY environment variable"))]
187pub struct ApiKeyUnspecified;
188
189impl ApiKey {
190    /// Retrieves the rocks API key from the `$LUX_API_KEY` environment
191    /// variable and seals it in this struct.
192    pub fn new() -> Result<Self, ApiKeyUnspecified> {
193        Ok(Self(
194            env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
195        ))
196    }
197
198    /// Creates an API key from a [`String`].
199    ///
200    /// # Safety
201    ///
202    /// This struct is designed to be sealed without a [`Display`](std::fmt::Display) implementation
203    /// so that it can never accidentally be printed.
204    ///
205    /// Ensure that you do not do anything else with the API key string prior to sealing it in this
206    /// struct.
207    pub fn from(str: &str) -> Self {
208        Self(str.to_string())
209    }
210
211    /// Retrieves the underlying API key as a [`String`].
212    ///
213    /// # Safety
214    ///
215    /// Strings may accidentally be printed as part of its [`Display`](std::fmt::Display)
216    /// implementation. Ensure that you never pass this variable somewhere it may be displayed.
217    pub unsafe fn get(&self) -> &str {
218        &self.0
219    }
220}
221
222/// 2FA token.
223/// This struct is designed to be sealed without a [`Display`](std::fmt::Display) implementation
224/// so that it can never accidentally be printed.
225struct TfaToken(String);
226
227impl TfaToken {
228    /// Retrieves the underlying 2FA token as a [`String`].
229    ///
230    /// # Safety
231    ///
232    /// Strings may accidentally be printed as part of its [`Display`](std::fmt::Display)
233    /// implementation. Ensure that you never pass this variable somewhere it may be displayed.
234    unsafe fn get(&self) -> &str {
235        &self.0
236    }
237}
238
239impl<'de> Deserialize<'de> for TfaToken {
240    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
241    where
242        D: serde::Deserializer<'de>,
243    {
244        String::deserialize(deserializer).map(Self)
245    }
246}
247
248#[derive(Deserialize)]
249struct LuarocksTfaVerificationSuccess {
250    tfa_token: TfaToken,
251}
252
253/// Models the response received from luarocks.org
254#[derive(Deserialize)]
255#[serde(untagged)]
256enum LuarocksTfaVerificationResponse {
257    Success(LuarocksTfaVerificationSuccess),
258    Failure(LuarocksErrorResponse),
259}
260
261#[derive(Deserialize)]
262struct LuarocksErrorResponse {
263    errors: Vec<String>,
264}
265
266#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
267#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
268#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
269#[serde(rename_all = "lowercase")]
270#[derive(Default)]
271#[cfg(not(feature = "gpgme"))]
272pub enum SignatureProtocol {
273    #[default]
274    None,
275}
276
277#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
278#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
279#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
280#[serde(rename_all = "lowercase")]
281#[derive(Default)]
282#[cfg(feature = "gpgme")]
283pub enum SignatureProtocol {
284    None,
285    Assuan,
286    CMS,
287    #[default]
288    Default,
289    G13,
290    GPGConf,
291    OpenPGP,
292    Spawn,
293    UIServer,
294}
295
296#[cfg(feature = "gpgme")]
297impl From<SignatureProtocol> for gpgme::Protocol {
298    fn from(val: SignatureProtocol) -> Self {
299        match val {
300            SignatureProtocol::Default => gpgme::Protocol::Default,
301            SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
302            SignatureProtocol::CMS => gpgme::Protocol::Cms,
303            SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
304            SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
305            SignatureProtocol::G13 => gpgme::Protocol::G13,
306            SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
307            SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
308            SignatureProtocol::None => unreachable!(),
309        }
310    }
311}
312
313#[tracing::instrument(
314    name = "Uploading",
315    level = "info",
316    skip_all,
317    fields(project = args.project.toml().package().to_string()),
318)]
319async fn upload_from_project(args: ProjectUpload<'_>) -> Result<(), UploadError> {
320    let project = args.project;
321    let api_key = args.api_key.unwrap_or(ApiKey::new()?);
322    #[cfg(feature = "gpgme")]
323    let protocol = args.sign_protocol;
324    let config = args.config;
325    let package_db = args.package_db;
326
327    let client = crate::reqwest::https_client(args.config)?;
328
329    helpers::ensure_tool_version(client, config.server()).await?;
330    helpers::ensure_user_exists(client, &api_key, config.server()).await?;
331
332    let (rockspec, rockspec_content) =
333        helpers::generate_rockspec(project, client, &api_key, config, package_db).await?;
334
335    #[cfg(not(feature = "gpgme"))]
336    let signed: Option<String> = None;
337
338    #[cfg(feature = "gpgme")]
339    let signed = if let SignatureProtocol::None = protocol {
340        None
341    } else {
342        let mut ctx = Context::from_protocol(protocol.into())?;
343        let mut signature = Data::new()?;
344
345        ctx.set_armor(true);
346        ctx.sign_detached(rockspec_content.clone(), &mut signature)?;
347
348        let mut signature_str = String::new();
349        signature.read_to_string(&mut signature_str)?;
350
351        Some(signature_str)
352    };
353
354    let rockspec = Part::text(rockspec_content)
355        .file_name(format!(
356            "{}-{}.rockspec",
357            rockspec.package(),
358            rockspec.version()
359        ))
360        .mime_str("application/octet-stream")?;
361
362    let multipart = {
363        let multipart = Form::new().part("rockspec_file", rockspec);
364
365        match signed {
366            Some(signature) => {
367                let part = Part::text(signature).file_name("project.rockspec.sig");
368                multipart.part("rockspec_sig", part)
369            }
370            None => multipart,
371        }
372    };
373
374    let mut request = client
375        .post(unsafe { helpers::url_for_method(config.server(), &api_key, "upload")? })
376        .multipart(multipart);
377
378    if let Some(code) = args.tfa_code {
379        let token = helpers::verify_tfa_code(client, config.server(), &api_key, &code).await?;
380        request = request.header(TFA_TOKEN_HEADER, unsafe { token.get() });
381    }
382
383    let response = request.send().await?;
384
385    let status = response.status();
386    if status.is_server_error() {
387        Err(UploadError::Server(config.server().clone(), status))
388    } else if status.is_success() {
389        Ok(())
390    } else {
391        let response = response.json::<LuarocksErrorResponse>().await?;
392        let errors = response.errors.into_iter().join("\n");
393        Err(UploadError::Client(config.server().clone(), errors))
394    }
395}
396
397mod helpers {
398    use std::collections::HashMap;
399
400    use super::*;
401    use crate::hash::HasIntegrity;
402    use crate::operations::Download;
403    use crate::package::{PackageName, PackageSpec, PackageVersion};
404    use crate::project::project_toml::RemoteProjectToml;
405    use crate::upload::RockCheckError;
406    use crate::upload::{ToolCheckError, UserCheckError};
407    use itertools::Itertools;
408    use reqwest::Client;
409    use ssri::Integrity;
410    use url::Url;
411
412    /// WARNING: This function is unsafe,
413    /// because it adds the unmasked API key to the URL.
414    /// When using URLs created by this function,
415    /// pay attention not to leak the API key in errors.
416    pub(crate) unsafe fn url_for_method(
417        server_url: &Url,
418        api_key: &ApiKey,
419        endpoint: &str,
420    ) -> Result<Url, url::ParseError> {
421        server_url
422            .join("api/1/")?
423            .join(&format!("{}/", api_key.get()))?
424            .join(endpoint)
425    }
426
427    #[tracing::instrument(level = "trace", skip(client))]
428    pub(crate) async fn ensure_tool_version(
429        client: &Client,
430        server_url: &Url,
431    ) -> Result<(), ToolCheckError> {
432        let url = server_url.join("api/tool_version")?;
433        let response: VersionCheckResponse = client
434            .post(url)
435            .json(&("current", TOOL_VERSION))
436            .send()
437            .await?
438            .json()
439            .await?;
440
441        if response.version == TOOL_VERSION {
442            Ok(())
443        } else {
444            Err(ToolCheckError::ToolOutdated(
445                server_url.to_string(),
446                response,
447            ))
448        }
449    }
450
451    #[tracing::instrument(level = "trace", skip(client, api_key))]
452    pub(crate) async fn verify_tfa_code(
453        client: &Client,
454        server_url: &Url,
455        api_key: &ApiKey,
456        tfa_code: &str,
457    ) -> Result<TfaToken, UploadError> {
458        let response = client
459            .get(unsafe { url_for_method(server_url, api_key, "verify_tfa")? })
460            .query(&("code", tfa_code.to_string()))
461            .send()
462            .await?;
463        let status = response.status();
464        if status.is_server_error() {
465            Err(UploadError::Server(server_url.clone(), status))
466        } else {
467            match response.json::<LuarocksTfaVerificationResponse>().await? {
468                LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess {
469                    tfa_token,
470                }) => Ok(tfa_token),
471                LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { errors }) => {
472                    Err(UploadError::TfaCodeRejected(
473                        tfa_code.to_string(),
474                        errors.into_iter().join("\n"),
475                    ))
476                }
477            }
478        }
479    }
480
481    #[tracing::instrument(level = "trace", skip(client, api_key))]
482    pub(crate) async fn ensure_user_exists(
483        client: &Client,
484        api_key: &ApiKey,
485        server_url: &Url,
486    ) -> Result<(), UserCheckError> {
487        let response = client
488            .get(unsafe { url_for_method(server_url, api_key, "status")? })
489            .send()
490            .await?;
491        let status = response.status();
492        if status.is_client_error() {
493            Err(UserCheckError::UserNotFound)
494        } else if status.is_server_error() {
495            Err(UserCheckError::Server(server_url.clone(), status))
496        } else {
497            Ok(())
498        }
499    }
500
501    #[tracing::instrument(level = "trace", skip_all)]
502    pub(crate) async fn generate_rockspec(
503        project: &Project,
504        client: &Client,
505        api_key: &ApiKey,
506        config: &Config,
507        package_db: &RemotePackageDB,
508    ) -> Result<(RemoteProjectToml, String), UploadError> {
509        for specrev in SpecRevIterator::new() {
510            let rockspec = project.toml().into_remote(Some(specrev))?;
511
512            let rockspec_content = rockspec
513                .to_lua_remote_rockspec_string()
514                .map_err(|err| UploadError::Rockspec(err.to_string()))?;
515
516            if let PackageVersion::StringVer(ver) = rockspec.version() {
517                return Err(UploadError::UnsupportedVersion(ver.to_string()));
518            }
519            if helpers::rock_exists(
520                client,
521                api_key,
522                rockspec.package(),
523                rockspec.version(),
524                config.server(),
525            )
526            .await?
527            {
528                let package =
529                    PackageSpec::new(rockspec.package().clone(), rockspec.version().clone());
530                let existing_rockspec = Download::new(&package.into(), config)
531                    .package_db(package_db)
532                    .download_rockspec()
533                    .await?
534                    .rockspec;
535                let existing_rockspec_hash =
536                    existing_rockspec.hash().await.map_err(UploadError::Hash)?;
537                let rockspec_content_hash = Integrity::from(&rockspec_content);
538                if existing_rockspec_hash
539                    .matches(&rockspec_content_hash)
540                    .is_some()
541                {
542                    return Err(UploadError::RockExists(config.server().clone()));
543                }
544            } else {
545                return Ok((rockspec, rockspec_content));
546            }
547        }
548        Err(UploadError::MaxSpecRevsExceeded)
549    }
550
551    #[tracing::instrument(level = "trace", skip(client, api_key))]
552    async fn rock_exists(
553        client: &Client,
554        api_key: &ApiKey,
555        name: &PackageName,
556        version: &PackageVersion,
557        server: &Url,
558    ) -> Result<bool, RockCheckError> {
559        let server_response_raw_json = client
560            .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
561            .query(&(
562                ("package", name.to_string()),
563                ("version", version.to_string()),
564            ))
565            .send()
566            .await?
567            .error_for_status()?
568            .text()
569            .await?;
570        let response_map: Option<HashMap<String, serde_json::Value>> =
571            serde_json::from_str(&server_response_raw_json).ok();
572        Ok(response_map.is_some_and(|response_map| {
573            response_map.contains_key("module") && response_map.contains_key("version")
574        }))
575    }
576}
577
578#[cfg(test)]
579mod test {
580    use super::*;
581
582    #[test]
583    fn test_deserialize_tfa_success() {
584        let response_str = r#"{
585    "success": true,
586    "expires": 1782939987,
587    "tfa_token": "dummy_token"
588}
589"#;
590        let result = serde_json::from_str(response_str).unwrap();
591        assert!(matches!(
592            result,
593            LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess { .. })
594        ));
595    }
596
597    #[test]
598    fn test_deserialize_tfa_failure() {
599        let response_str = r#"{
600    "errors": [
601        "Invalid verification code"
602    ]
603}
604"#;
605        let result = serde_json::from_str(response_str).unwrap();
606        assert!(matches!(
607            result,
608            LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { .. })
609        ));
610    }
611
612    #[test]
613    fn test_deserialize_luarocks_error_response() {
614        let response_str = r#"{
615    "errors": [
616        "Invalid verification code"
617    ]
618}
619"#;
620        let result = serde_json::from_str(response_str).unwrap();
621        assert!(matches!(result, LuarocksErrorResponse { .. }));
622    }
623}