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#[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 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}")] 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 pub fn new() -> Result<Self, ApiKeyUnspecified> {
144 Ok(Self(
145 env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
146 ))
147 }
148
149 pub unsafe fn from(str: String) -> Self {
159 Self(str)
160 }
161
162 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 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}