lux_lib/upload/
mod.rs

1use std::env;
2
3use crate::package::PackageVersion;
4use crate::project::project_toml::RemoteProjectTomlValidationError;
5use crate::rockspec::Rockspec;
6use crate::TOOL_VERSION;
7use crate::{config::Config, project::Project};
8
9use reqwest::StatusCode;
10use reqwest::{
11    multipart::{Form, Part},
12    Client,
13};
14use serde::Deserialize;
15use serde_enum_str::Serialize_enum_str;
16use thiserror::Error;
17use url::Url;
18
19#[cfg(not(target_env = "msvc"))]
20use gpgme::{Context, Data};
21#[cfg(not(target_env = "msvc"))]
22use std::io::Read;
23
24/// A rocks package uploader, providing fine-grained control
25/// over how a package should be uploaded.
26pub struct ProjectUpload<'a> {
27    project: Project,
28    api_key: Option<ApiKey>,
29    sign_protocol: SignatureProtocol,
30    config: &'a Config,
31}
32
33impl<'a> ProjectUpload<'a> {
34    /// Construct a new package uploader.
35    pub fn new(project: Project, config: &'a Config) -> Self {
36        Self {
37            project,
38            api_key: None,
39            sign_protocol: SignatureProtocol::default(),
40            config,
41        }
42    }
43
44    /// Set the luarocks API key.
45    pub fn api_key(self, api_key: ApiKey) -> Self {
46        Self {
47            api_key: Some(api_key),
48            ..self
49        }
50    }
51
52    /// Set the signature protocol.
53    pub fn sign_protocol(self, sign_protocol: SignatureProtocol) -> Self {
54        Self {
55            sign_protocol,
56            ..self
57        }
58    }
59
60    /// Upload a package to a luarocks server.
61    pub async fn upload_to_luarocks(self) -> Result<(), UploadError> {
62        let api_key = self.api_key.unwrap_or(ApiKey::new()?);
63        upload_from_project(&self.project, &api_key, self.sign_protocol, self.config).await
64    }
65}
66
67#[derive(Deserialize, Debug)]
68pub struct VersionCheckResponse {
69    version: String,
70}
71
72#[derive(Error, Debug)]
73pub enum ToolCheckError {
74    #[error("error parsing tool check URL: {0}")]
75    ParseError(#[from] url::ParseError),
76    #[error(transparent)]
77    Request(#[from] reqwest::Error),
78    #[error("`lux` is out of date with {0}'s expected tool version! `lux` is at version {TOOL_VERSION}, server is at {server_version}", server_version = _1.version)]
79    ToolOutdated(String, VersionCheckResponse),
80}
81
82#[derive(Error, Debug)]
83pub enum UserCheckError {
84    #[error("error parsing user check URL: {0}")]
85    ParseError(#[from] url::ParseError),
86    #[error(transparent)]
87    Request(#[from] reqwest::Error),
88    #[error("invalid API key provided")]
89    UserNotFound,
90    #[error("server {0} responded with error status: {1}")]
91    Server(Url, StatusCode),
92}
93
94#[derive(Error, Debug)]
95#[error("could not check rock status on server: {0}")]
96pub enum RockCheckError {
97    #[error(transparent)]
98    ParseError(#[from] url::ParseError),
99    #[error(transparent)]
100    Request(#[from] reqwest::Error),
101}
102
103#[derive(Error, Debug)]
104#[error(transparent)]
105pub enum UploadError {
106    #[error("error parsing upload URL: {0}")]
107    ParseError(#[from] url::ParseError),
108    Lua(#[from] mlua::Error),
109    Request(#[from] reqwest::Error),
110    #[error("server {0} responded with error status: {1}")]
111    Server(Url, StatusCode),
112    #[error("client error when requesting {0}\nStatus code: {1}")]
113    Client(Url, StatusCode),
114    RockCheck(#[from] RockCheckError),
115    #[error("rock already exists on server: {0}")]
116    RockExists(Url),
117    #[error("unable to read rockspec: {0}")]
118    RockspecRead(#[from] std::io::Error),
119    #[cfg(not(target_env = "msvc"))]
120    #[error("{0}.\nHINT: If you'd like to skip the signing step supply `--sign-protocol none` to the CLI")]
121    Signature(#[from] gpgme::Error),
122    ToolCheck(#[from] ToolCheckError),
123    UserCheck(#[from] UserCheckError),
124    ApiKeyUnspecified(#[from] ApiKeyUnspecified),
125    ValidationError(#[from] RemoteProjectTomlValidationError),
126    #[error(
127        "unsupported version: `{0}`.\nLux can upload packages with a SemVer version, 'dev' or 'scm'"
128    )]
129    UnsupportedVersion(String),
130    #[error("{0}")] // We don't know the concrete error type
131    Rockspec(String),
132}
133
134pub struct ApiKey(String);
135
136#[derive(Error, Debug)]
137#[error("no API key provided! Please set the $LUX_API_KEY 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")]
177pub enum SignatureProtocol {
178    None,
179    #[cfg(not(target_env = "msvc"))]
180    Assuan,
181    #[cfg(not(target_env = "msvc"))]
182    CMS,
183    #[cfg(not(target_env = "msvc"))]
184    Default,
185    #[cfg(not(target_env = "msvc"))]
186    G13,
187    #[cfg(not(target_env = "msvc"))]
188    GPGConf,
189    #[cfg(not(target_env = "msvc"))]
190    OpenPGP,
191    #[cfg(not(target_env = "msvc"))]
192    Spawn,
193    #[cfg(not(target_env = "msvc"))]
194    UIServer,
195}
196
197#[cfg(not(target_env = "msvc"))]
198impl Default for SignatureProtocol {
199    fn default() -> Self {
200        Self::Default
201    }
202}
203
204#[cfg(target_env = "msvc")]
205impl Default for SignatureProtocol {
206    fn default() -> Self {
207        Self::None
208    }
209}
210
211#[cfg(not(target_env = "msvc"))]
212impl From<SignatureProtocol> for gpgme::Protocol {
213    fn from(val: SignatureProtocol) -> Self {
214        match val {
215            SignatureProtocol::Default => gpgme::Protocol::Default,
216            SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
217            SignatureProtocol::CMS => gpgme::Protocol::Cms,
218            SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
219            SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
220            SignatureProtocol::G13 => gpgme::Protocol::G13,
221            SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
222            SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
223            SignatureProtocol::None => unreachable!(),
224        }
225    }
226}
227
228async fn upload_from_project(
229    project: &Project,
230    api_key: &ApiKey,
231    #[cfg(target_env = "msvc")] _protocol: SignatureProtocol,
232    #[cfg(not(target_env = "msvc"))] protocol: SignatureProtocol,
233    config: &Config,
234) -> Result<(), UploadError> {
235    let client = Client::builder().https_only(true).build()?;
236
237    let rockspec = project.toml().into_remote()?;
238
239    if let PackageVersion::StringVer(ver) = rockspec.version() {
240        return Err(UploadError::UnsupportedVersion(ver.to_string()));
241    }
242
243    helpers::ensure_tool_version(&client, config.server()).await?;
244    helpers::ensure_user_exists(&client, api_key, config.server()).await?;
245
246    if helpers::rock_exists(
247        &client,
248        api_key,
249        rockspec.package(),
250        rockspec.version(),
251        config.server(),
252    )
253    .await?
254    {
255        return Err(UploadError::RockExists(config.server().clone()));
256    }
257
258    let rockspec_content = rockspec
259        .to_lua_remote_rockspec_string()
260        .map_err(|err| UploadError::Rockspec(err.to_string()))?;
261
262    #[cfg(target_env = "msvc")]
263    let signed: Option<String> = None;
264
265    #[cfg(not(target_env = "msvc"))]
266    let signed = if let SignatureProtocol::None = protocol {
267        None
268    } else {
269        let mut ctx = Context::from_protocol(protocol.into())?;
270        let mut signature = Data::new()?;
271
272        ctx.set_armor(true);
273        ctx.sign_detached(rockspec_content.clone(), &mut signature)?;
274
275        let mut signature_str = String::new();
276        signature.read_to_string(&mut signature_str)?;
277
278        Some(signature_str)
279    };
280
281    let rockspec = Part::text(rockspec_content)
282        .file_name(format!(
283            "{}-{}.rockspec",
284            rockspec.package(),
285            rockspec.version()
286        ))
287        .mime_str("application/octet-stream")?;
288
289    let multipart = {
290        let multipart = Form::new().part("rockspec_file", rockspec);
291
292        match signed {
293            Some(signature) => {
294                let part = Part::text(signature).file_name("project.rockspec.sig");
295                multipart.part("rockspec_sig", part)
296            }
297            None => multipart,
298        }
299    };
300
301    let response = client
302        .post(unsafe { helpers::url_for_method(config.server(), api_key, "upload")? })
303        .multipart(multipart)
304        .send()
305        .await?;
306
307    let status = response.status();
308    if status.is_client_error() {
309        Err(UploadError::Client(config.server().clone(), status))
310    } else if status.is_server_error() {
311        Err(UploadError::Server(config.server().clone(), status))
312    } else {
313        Ok(())
314    }
315}
316
317mod helpers {
318    use super::*;
319    use crate::package::{PackageName, PackageVersion};
320    use crate::upload::RockCheckError;
321    use crate::upload::{ToolCheckError, UserCheckError};
322    use reqwest::Client;
323    use url::Url;
324
325    /// WARNING: This function is unsafe,
326    /// because it adds the unmasked API key to the URL.
327    /// When using URLs created by this function,
328    /// pay attention not to leak the API key in errors.
329    pub(crate) unsafe fn url_for_method(
330        server_url: &Url,
331        api_key: &ApiKey,
332        endpoint: &str,
333    ) -> Result<Url, url::ParseError> {
334        server_url
335            .join("api/1/")
336            .expect("error constructing 'api/1/' path")
337            .join(&format!("{}/", api_key.get()))?
338            .join(endpoint)
339    }
340
341    pub(crate) async fn ensure_tool_version(
342        client: &Client,
343        server_url: &Url,
344    ) -> Result<(), ToolCheckError> {
345        let url = server_url.join("api/tool_version")?;
346        let response: VersionCheckResponse = client
347            .post(url)
348            .json(&("current", TOOL_VERSION))
349            .send()
350            .await?
351            .json()
352            .await?;
353
354        if response.version == TOOL_VERSION {
355            Ok(())
356        } else {
357            Err(ToolCheckError::ToolOutdated(
358                server_url.to_string(),
359                response,
360            ))
361        }
362    }
363
364    pub(crate) async fn ensure_user_exists(
365        client: &Client,
366        api_key: &ApiKey,
367        server_url: &Url,
368    ) -> Result<(), UserCheckError> {
369        let response = client
370            .get(unsafe { url_for_method(server_url, api_key, "status")? })
371            .send()
372            .await?;
373        let status = response.status();
374        if status.is_client_error() {
375            Err(UserCheckError::UserNotFound)
376        } else if status.is_server_error() {
377            Err(UserCheckError::Server(server_url.clone(), status))
378        } else {
379            Ok(())
380        }
381    }
382
383    pub(crate) async fn rock_exists(
384        client: &Client,
385        api_key: &ApiKey,
386        name: &PackageName,
387        version: &PackageVersion,
388        server: &Url,
389    ) -> Result<bool, RockCheckError> {
390        Ok(client
391            .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
392            .query(&(
393                ("package", name.to_string()),
394                ("version", version.to_string()),
395            ))
396            .send()
397            .await?
398            .text()
399            .await?
400            != "{}")
401    }
402}