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