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 reqwest::StatusCode;
14use reqwest::{
15    multipart::{Form, Part},
16    Client,
17};
18use serde::Deserialize;
19use serde_enum_str::Serialize_enum_str;
20use thiserror::Error;
21use url::Url;
22
23#[cfg(feature = "gpgme")]
24use gpgme::{Context, Data};
25#[cfg(feature = "gpgme")]
26use std::io::Read;
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: Project,
34    api_key: Option<ApiKey>,
35    #[cfg(feature = "gpgme")]
36    sign_protocol: SignatureProtocol,
37    config: &'a Config,
38    progress: &'a Progress<ProgressBar>,
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)]
59pub enum ToolCheckError {
60    #[error("error parsing tool check URL:\n{0}")]
61    ParseError(#[from] url::ParseError),
62    #[error("error sending HTTP request:\n{0}")]
63    Request(#[from] reqwest::Error),
64    #[error(r#"`lux` is out of date with {0}'s expected tool version.
65    `lux` is at version {TOOL_VERSION}, server is at {server_version}"#, server_version = _1.version)]
66    ToolOutdated(String, VersionCheckResponse),
67}
68
69#[derive(Error, Debug)]
70pub enum UserCheckError {
71    #[error("error parsing user check URL:\n{0}")]
72    ParseError(#[from] url::ParseError),
73    #[error(transparent)]
74    Request(#[from] reqwest::Error),
75    #[error("invalid API key provided")]
76    UserNotFound,
77    #[error("server {0} responded with error status: {1}")]
78    Server(Url, StatusCode),
79}
80
81#[derive(Error, Debug)]
82pub enum RockCheckError {
83    #[error("parse error while checking rock status on server:\n{0}")]
84    ParseError(#[from] url::ParseError),
85    #[error("HTTP request error while checking rock status on server:\n{0}")]
86    Request(#[from] reqwest::Error),
87}
88
89#[derive(Error, Debug)]
90#[error(transparent)]
91pub enum UploadError {
92    #[error("error parsing upload URL:\n{0}")]
93    ParseError(#[from] url::ParseError),
94    #[error("Lua error while uploading:\n{0}")]
95    Lua(#[from] mlua::Error),
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}\nStatus code: {1}")]
101    Client(Url, StatusCode),
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    SearchAndDownload(#[from] SearchAndDownloadError),
130    #[error("error computing rockspec hash:\n{0}")]
131    Hash(io::Error),
132}
133
134pub struct ApiKey(String);
135
136#[derive(Error, Debug)]
137#[error("no API key provided! Please set the $LUX_API_KEY environment variable")]
138pub struct ApiKeyUnspecified;
139
140impl ApiKey {
141    /// Retrieves the rocks API key from the `$LUX_API_KEY` environment
142    /// variable and seals it in this struct.
143    pub fn new() -> Result<Self, ApiKeyUnspecified> {
144        Ok(Self(
145            env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
146        ))
147    }
148
149    /// Creates an API key from a String.
150    ///
151    /// # Safety
152    ///
153    /// This struct is designed to be sealed without a [`Display`](std::fmt::Display) implementation
154    /// so that it can never accidentally be printed.
155    ///
156    /// Ensure that you do not do anything else with the API key string prior to sealing it in this
157    /// struct.
158    pub unsafe fn from(str: String) -> Self {
159        Self(str)
160    }
161
162    /// Retrieves the underlying API key as a [`String`].
163    ///
164    /// # Safety
165    ///
166    /// Strings may accidentally be printed as part of its [`Display`](std::fmt::Display)
167    /// implementation. Ensure that you never pass this variable somewhere it may be displayed.
168    pub unsafe fn get(&self) -> &String {
169        &self.0
170    }
171}
172
173#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
174#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
175#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
176#[serde(rename_all = "lowercase")]
177#[derive(Default)]
178#[cfg(not(feature = "gpgme"))]
179pub enum SignatureProtocol {
180    #[default]
181    None,
182}
183
184#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
185#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
186#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
187#[serde(rename_all = "lowercase")]
188#[derive(Default)]
189#[cfg(feature = "gpgme")]
190pub enum SignatureProtocol {
191    None,
192    Assuan,
193    CMS,
194    #[default]
195    Default,
196    G13,
197    GPGConf,
198    OpenPGP,
199    Spawn,
200    UIServer,
201}
202
203#[cfg(feature = "gpgme")]
204impl From<SignatureProtocol> for gpgme::Protocol {
205    fn from(val: SignatureProtocol) -> Self {
206        match val {
207            SignatureProtocol::Default => gpgme::Protocol::Default,
208            SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
209            SignatureProtocol::CMS => gpgme::Protocol::Cms,
210            SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
211            SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
212            SignatureProtocol::G13 => gpgme::Protocol::G13,
213            SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
214            SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
215            SignatureProtocol::None => unreachable!(),
216        }
217    }
218}
219
220async fn upload_from_project(args: ProjectUpload<'_>) -> Result<(), UploadError> {
221    let project = args.project;
222    let api_key = args.api_key.unwrap_or(ApiKey::new()?);
223    #[cfg(feature = "gpgme")]
224    let protocol = args.sign_protocol;
225    let config = args.config;
226    let progress = args.progress;
227    let package_db = args.package_db;
228
229    let client = Client::builder().https_only(true).build()?;
230
231    helpers::ensure_tool_version(&client, config.server()).await?;
232    helpers::ensure_user_exists(&client, &api_key, config.server()).await?;
233
234    let (rockspec, rockspec_content) =
235        helpers::generate_rockspec(&project, &client, &api_key, config, progress, package_db)
236            .await?;
237
238    #[cfg(not(feature = "gpgme"))]
239    let signed: Option<String> = None;
240
241    #[cfg(feature = "gpgme")]
242    let signed = if let SignatureProtocol::None = protocol {
243        None
244    } else {
245        let mut ctx = Context::from_protocol(protocol.into())?;
246        let mut signature = Data::new()?;
247
248        ctx.set_armor(true);
249        ctx.sign_detached(rockspec_content.clone(), &mut signature)?;
250
251        let mut signature_str = String::new();
252        signature.read_to_string(&mut signature_str)?;
253
254        Some(signature_str)
255    };
256
257    let rockspec = Part::text(rockspec_content)
258        .file_name(format!(
259            "{}-{}.rockspec",
260            rockspec.package(),
261            rockspec.version()
262        ))
263        .mime_str("application/octet-stream")?;
264
265    let multipart = {
266        let multipart = Form::new().part("rockspec_file", rockspec);
267
268        match signed {
269            Some(signature) => {
270                let part = Part::text(signature).file_name("project.rockspec.sig");
271                multipart.part("rockspec_sig", part)
272            }
273            None => multipart,
274        }
275    };
276
277    let response = client
278        .post(unsafe { helpers::url_for_method(config.server(), &api_key, "upload")? })
279        .multipart(multipart)
280        .send()
281        .await?;
282
283    let status = response.status();
284    if status.is_client_error() {
285        Err(UploadError::Client(config.server().clone(), status))
286    } else if status.is_server_error() {
287        Err(UploadError::Server(config.server().clone(), status))
288    } else {
289        Ok(())
290    }
291}
292
293mod helpers {
294    use std::collections::HashMap;
295
296    use super::*;
297    use crate::hash::HasIntegrity;
298    use crate::operations::Download;
299    use crate::package::{PackageName, PackageSpec, PackageVersion};
300    use crate::project::project_toml::RemoteProjectToml;
301    use crate::upload::RockCheckError;
302    use crate::upload::{ToolCheckError, UserCheckError};
303    use reqwest::Client;
304    use ssri::Integrity;
305    use url::Url;
306
307    /// WARNING: This function is unsafe,
308    /// because it adds the unmasked API key to the URL.
309    /// When using URLs created by this function,
310    /// pay attention not to leak the API key in errors.
311    pub(crate) unsafe fn url_for_method(
312        server_url: &Url,
313        api_key: &ApiKey,
314        endpoint: &str,
315    ) -> Result<Url, url::ParseError> {
316        server_url
317            .join("api/1/")?
318            .join(&format!("{}/", api_key.get()))?
319            .join(endpoint)
320    }
321
322    pub(crate) async fn ensure_tool_version(
323        client: &Client,
324        server_url: &Url,
325    ) -> Result<(), ToolCheckError> {
326        let url = server_url.join("api/tool_version")?;
327        let response: VersionCheckResponse = client
328            .post(url)
329            .json(&("current", TOOL_VERSION))
330            .send()
331            .await?
332            .json()
333            .await?;
334
335        if response.version == TOOL_VERSION {
336            Ok(())
337        } else {
338            Err(ToolCheckError::ToolOutdated(
339                server_url.to_string(),
340                response,
341            ))
342        }
343    }
344
345    pub(crate) async fn ensure_user_exists(
346        client: &Client,
347        api_key: &ApiKey,
348        server_url: &Url,
349    ) -> Result<(), UserCheckError> {
350        let response = client
351            .get(unsafe { url_for_method(server_url, api_key, "status")? })
352            .send()
353            .await?;
354        let status = response.status();
355        if status.is_client_error() {
356            Err(UserCheckError::UserNotFound)
357        } else if status.is_server_error() {
358            Err(UserCheckError::Server(server_url.clone(), status))
359        } else {
360            Ok(())
361        }
362    }
363
364    pub(crate) async fn generate_rockspec(
365        project: &Project,
366        client: &Client,
367        api_key: &ApiKey,
368        config: &Config,
369        progress: &Progress<ProgressBar>,
370        package_db: &RemotePackageDB,
371    ) -> Result<(RemoteProjectToml, String), UploadError> {
372        for specrev in SpecRevIterator::new() {
373            let rockspec = project.toml().into_remote(Some(specrev))?;
374
375            let rockspec_content = rockspec
376                .to_lua_remote_rockspec_string()
377                .map_err(|err| UploadError::Rockspec(err.to_string()))?;
378
379            if let PackageVersion::StringVer(ver) = rockspec.version() {
380                return Err(UploadError::UnsupportedVersion(ver.to_string()));
381            }
382            if helpers::rock_exists(
383                client,
384                api_key,
385                rockspec.package(),
386                rockspec.version(),
387                config.server(),
388            )
389            .await?
390            {
391                let package =
392                    PackageSpec::new(rockspec.package().clone(), rockspec.version().clone());
393                let existing_rockspec = Download::new(&package.into(), config, progress)
394                    .package_db(package_db)
395                    .download_rockspec()
396                    .await?
397                    .rockspec;
398                let existing_rockspec_hash = existing_rockspec.hash().map_err(UploadError::Hash)?;
399                let rockspec_content_hash = Integrity::from(&rockspec_content);
400                if existing_rockspec_hash
401                    .matches(&rockspec_content_hash)
402                    .is_some()
403                {
404                    return Err(UploadError::RockExists(config.server().clone()));
405                }
406            } else {
407                return Ok((rockspec, rockspec_content));
408            }
409        }
410        Err(UploadError::MaxSpecRevsExceeded)
411    }
412
413    async fn rock_exists(
414        client: &Client,
415        api_key: &ApiKey,
416        name: &PackageName,
417        version: &PackageVersion,
418        server: &Url,
419    ) -> Result<bool, RockCheckError> {
420        let server_response_raw_json = client
421            .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
422            .query(&(
423                ("package", name.to_string()),
424                ("version", version.to_string()),
425            ))
426            .send()
427            .await?
428            .error_for_status()?
429            .text()
430            .await?;
431        let response_map: Option<HashMap<String, serde_json::Value>> =
432            serde_json::from_str(&server_response_raw_json).ok();
433        Ok(response_map.is_some_and(|response_map| {
434            response_map.contains_key("module") && response_map.contains_key("version")
435        }))
436    }
437}