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