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