Skip to main content

lux_lib/upload/
mod.rs

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