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 itertools::Itertools;
14use miette::Diagnostic;
15use reqwest::multipart::{Form, Part};
16use reqwest::StatusCode;
17use serde::Deserialize;
18use serde_enum_str::Serialize_enum_str;
19use thiserror::Error;
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#[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 progress: &'a Progress<ProgressBar>,
41 package_db: &'a RemotePackageDB,
42}
43
44impl<State> ProjectUploadBuilder<'_, State>
45where
46 State: project_upload_builder::State + project_upload_builder::IsComplete,
47{
48 pub async fn upload_to_luarocks(self) -> Result<(), UploadError> {
50 let args = self._build();
51 upload_from_project(args).await
52 }
53}
54
55#[derive(Deserialize, Debug)]
56pub struct VersionCheckResponse {
57 version: String,
58}
59
60#[derive(Error, Debug, Diagnostic)]
61pub enum ToolCheckError {
62 #[error("error parsing tool check URL:\n{0}")]
63 ParseError(#[from] url::ParseError),
64 #[error("error sending HTTP request:\n{0}")]
65 Request(#[from] reqwest::Error),
66 #[error(r#"`lux` is out of date with {0}'s expected tool version.
67 `lux` is at version {TOOL_VERSION}, server is at {server_version}"#, server_version = _1.version)]
68 ToolOutdated(String, VersionCheckResponse),
69}
70
71#[derive(Error, Debug, Diagnostic)]
72pub enum UserCheckError {
73 #[error("error parsing user check URL:\n{0}")]
74 ParseError(#[from] url::ParseError),
75 #[error(transparent)]
76 Request(#[from] reqwest::Error),
77 #[error("invalid API key provided")]
78 UserNotFound,
79 #[error("server {0} responded with error status: {1}")]
80 Server(Url, StatusCode),
81}
82
83#[derive(Error, Debug, Diagnostic)]
84pub enum RockCheckError {
85 #[error("parse error while checking rock status on server:\n{0}")]
86 ParseError(#[from] url::ParseError),
87 #[error("HTTP request error while checking rock status on server:\n{0}")]
88 Request(#[from] reqwest::Error),
89}
90
91#[derive(Error, Debug, Diagnostic)]
92#[error(transparent)]
93pub enum UploadError {
94 #[error("error parsing upload URL:\n{0}")]
95 ParseError(#[from] url::ParseError),
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}:\n{1}")]
101 Client(Url, String),
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 #[diagnostic(forward(0))]
130 SearchAndDownload(#[from] SearchAndDownloadError),
131 #[error("error computing rockspec hash:\n{0}")]
132 Hash(io::Error),
133 #[error("the 2FA code '{0}' was rejected by the server: {1}")]
134 TfaCodeRejected(String, String),
135}
136
137pub struct ApiKey(String);
138
139#[derive(Error, Debug, Diagnostic)]
140#[error("no API key provided! Please set the $LUX_API_KEY environment variable")]
141pub struct ApiKeyUnspecified;
142
143impl ApiKey {
144 pub fn new() -> Result<Self, ApiKeyUnspecified> {
147 Ok(Self(
148 env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
149 ))
150 }
151
152 pub fn from(str: &str) -> Self {
162 Self(str.to_string())
163 }
164
165 pub unsafe fn get(&self) -> &str {
172 &self.0
173 }
174}
175
176struct TfaToken(String);
180
181impl TfaToken {
182 unsafe fn get(&self) -> &str {
189 &self.0
190 }
191}
192
193impl<'de> Deserialize<'de> for TfaToken {
194 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
195 where
196 D: serde::Deserializer<'de>,
197 {
198 String::deserialize(deserializer).map(Self)
199 }
200}
201
202#[derive(Deserialize)]
203struct LuarocksTfaVerificationSuccess {
204 tfa_token: TfaToken,
205}
206
207#[derive(Deserialize)]
209#[serde(untagged)]
210enum LuarocksTfaVerificationResponse {
211 Success(LuarocksTfaVerificationSuccess),
212 Failure(LuarocksErrorResponse),
213}
214
215#[derive(Deserialize)]
216struct LuarocksErrorResponse {
217 errors: Vec<String>,
218}
219
220#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
221#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
222#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
223#[serde(rename_all = "lowercase")]
224#[derive(Default)]
225#[cfg(not(feature = "gpgme"))]
226pub enum SignatureProtocol {
227 #[default]
228 None,
229}
230
231#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
232#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
233#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
234#[serde(rename_all = "lowercase")]
235#[derive(Default)]
236#[cfg(feature = "gpgme")]
237pub enum SignatureProtocol {
238 None,
239 Assuan,
240 CMS,
241 #[default]
242 Default,
243 G13,
244 GPGConf,
245 OpenPGP,
246 Spawn,
247 UIServer,
248}
249
250#[cfg(feature = "gpgme")]
251impl From<SignatureProtocol> for gpgme::Protocol {
252 fn from(val: SignatureProtocol) -> Self {
253 match val {
254 SignatureProtocol::Default => gpgme::Protocol::Default,
255 SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
256 SignatureProtocol::CMS => gpgme::Protocol::Cms,
257 SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
258 SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
259 SignatureProtocol::G13 => gpgme::Protocol::G13,
260 SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
261 SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
262 SignatureProtocol::None => unreachable!(),
263 }
264 }
265}
266
267async fn upload_from_project(args: ProjectUpload<'_>) -> Result<(), UploadError> {
268 let project = args.project;
269 let api_key = args.api_key.unwrap_or(ApiKey::new()?);
270 #[cfg(feature = "gpgme")]
271 let protocol = args.sign_protocol;
272 let config = args.config;
273 let progress = args.progress;
274 let package_db = args.package_db;
275
276 let client = crate::reqwest::new_https_client(args.config)?;
277
278 helpers::ensure_tool_version(&client, config.server()).await?;
279 helpers::ensure_user_exists(&client, &api_key, config.server()).await?;
280
281 let (rockspec, rockspec_content) =
282 helpers::generate_rockspec(project, &client, &api_key, config, progress, package_db)
283 .await?;
284
285 #[cfg(not(feature = "gpgme"))]
286 let signed: Option<String> = None;
287
288 #[cfg(feature = "gpgme")]
289 let signed = if let SignatureProtocol::None = protocol {
290 None
291 } else {
292 let mut ctx = Context::from_protocol(protocol.into())?;
293 let mut signature = Data::new()?;
294
295 ctx.set_armor(true);
296 ctx.sign_detached(rockspec_content.clone(), &mut signature)?;
297
298 let mut signature_str = String::new();
299 signature.read_to_string(&mut signature_str)?;
300
301 Some(signature_str)
302 };
303
304 let rockspec = Part::text(rockspec_content)
305 .file_name(format!(
306 "{}-{}.rockspec",
307 rockspec.package(),
308 rockspec.version()
309 ))
310 .mime_str("application/octet-stream")?;
311
312 let multipart = {
313 let multipart = Form::new().part("rockspec_file", rockspec);
314
315 match signed {
316 Some(signature) => {
317 let part = Part::text(signature).file_name("project.rockspec.sig");
318 multipart.part("rockspec_sig", part)
319 }
320 None => multipart,
321 }
322 };
323
324 let mut request = client
325 .post(unsafe { helpers::url_for_method(config.server(), &api_key, "upload")? })
326 .multipart(multipart);
327
328 if let Some(code) = args.tfa_code {
329 let token = helpers::verify_tfa_code(&client, config.server(), &api_key, &code).await?;
330 request = request.header(TFA_TOKEN_HEADER, unsafe { token.get() });
331 }
332
333 let response = request.send().await?;
334
335 let status = response.status();
336 if status.is_server_error() {
337 Err(UploadError::Server(config.server().clone(), status))
338 } else if status.is_success() {
339 Ok(())
340 } else {
341 let response = response.json::<LuarocksErrorResponse>().await?;
342 let errors = response.errors.into_iter().join("\n");
343 Err(UploadError::Client(config.server().clone(), errors))
344 }
345}
346
347mod helpers {
348 use std::collections::HashMap;
349
350 use super::*;
351 use crate::hash::HasIntegrity;
352 use crate::operations::Download;
353 use crate::package::{PackageName, PackageSpec, PackageVersion};
354 use crate::project::project_toml::RemoteProjectToml;
355 use crate::upload::RockCheckError;
356 use crate::upload::{ToolCheckError, UserCheckError};
357 use itertools::Itertools;
358 use reqwest::Client;
359 use ssri::Integrity;
360 use url::Url;
361
362 pub(crate) unsafe fn url_for_method(
367 server_url: &Url,
368 api_key: &ApiKey,
369 endpoint: &str,
370 ) -> Result<Url, url::ParseError> {
371 server_url
372 .join("api/1/")?
373 .join(&format!("{}/", api_key.get()))?
374 .join(endpoint)
375 }
376
377 pub(crate) async fn ensure_tool_version(
378 client: &Client,
379 server_url: &Url,
380 ) -> Result<(), ToolCheckError> {
381 let url = server_url.join("api/tool_version")?;
382 let response: VersionCheckResponse = client
383 .post(url)
384 .json(&("current", TOOL_VERSION))
385 .send()
386 .await?
387 .json()
388 .await?;
389
390 if response.version == TOOL_VERSION {
391 Ok(())
392 } else {
393 Err(ToolCheckError::ToolOutdated(
394 server_url.to_string(),
395 response,
396 ))
397 }
398 }
399
400 pub(crate) async fn verify_tfa_code(
401 client: &Client,
402 server_url: &Url,
403 api_key: &ApiKey,
404 tfa_code: &str,
405 ) -> Result<TfaToken, UploadError> {
406 let response = client
407 .get(unsafe { url_for_method(server_url, api_key, "verify_tfa")? })
408 .query(&(("code", tfa_code.to_string()),))
409 .send()
410 .await?;
411 let status = response.status();
412 if status.is_server_error() {
413 Err(UploadError::Server(server_url.clone(), status))
414 } else {
415 match response.json::<LuarocksTfaVerificationResponse>().await? {
416 LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess {
417 tfa_token,
418 }) => Ok(tfa_token),
419 LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { errors }) => {
420 Err(UploadError::TfaCodeRejected(
421 tfa_code.to_string(),
422 errors.into_iter().join("\n"),
423 ))
424 }
425 }
426 }
427 }
428
429 pub(crate) async fn ensure_user_exists(
430 client: &Client,
431 api_key: &ApiKey,
432 server_url: &Url,
433 ) -> Result<(), UserCheckError> {
434 let response = client
435 .get(unsafe { url_for_method(server_url, api_key, "status")? })
436 .send()
437 .await?;
438 let status = response.status();
439 if status.is_client_error() {
440 Err(UserCheckError::UserNotFound)
441 } else if status.is_server_error() {
442 Err(UserCheckError::Server(server_url.clone(), status))
443 } else {
444 Ok(())
445 }
446 }
447
448 pub(crate) async fn generate_rockspec(
449 project: &Project,
450 client: &Client,
451 api_key: &ApiKey,
452 config: &Config,
453 progress: &Progress<ProgressBar>,
454 package_db: &RemotePackageDB,
455 ) -> Result<(RemoteProjectToml, String), UploadError> {
456 for specrev in SpecRevIterator::new() {
457 let rockspec = project.toml().into_remote(Some(specrev))?;
458
459 let rockspec_content = rockspec
460 .to_lua_remote_rockspec_string()
461 .map_err(|err| UploadError::Rockspec(err.to_string()))?;
462
463 if let PackageVersion::StringVer(ver) = rockspec.version() {
464 return Err(UploadError::UnsupportedVersion(ver.to_string()));
465 }
466 if helpers::rock_exists(
467 client,
468 api_key,
469 rockspec.package(),
470 rockspec.version(),
471 config.server(),
472 )
473 .await?
474 {
475 let package =
476 PackageSpec::new(rockspec.package().clone(), rockspec.version().clone());
477 let existing_rockspec = Download::new(&package.into(), config, progress)
478 .package_db(package_db)
479 .download_rockspec()
480 .await?
481 .rockspec;
482 let existing_rockspec_hash = existing_rockspec.hash().map_err(UploadError::Hash)?;
483 let rockspec_content_hash = Integrity::from(&rockspec_content);
484 if existing_rockspec_hash
485 .matches(&rockspec_content_hash)
486 .is_some()
487 {
488 return Err(UploadError::RockExists(config.server().clone()));
489 }
490 } else {
491 return Ok((rockspec, rockspec_content));
492 }
493 }
494 Err(UploadError::MaxSpecRevsExceeded)
495 }
496
497 async fn rock_exists(
498 client: &Client,
499 api_key: &ApiKey,
500 name: &PackageName,
501 version: &PackageVersion,
502 server: &Url,
503 ) -> Result<bool, RockCheckError> {
504 let server_response_raw_json = client
505 .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
506 .query(&(
507 ("package", name.to_string()),
508 ("version", version.to_string()),
509 ))
510 .send()
511 .await?
512 .error_for_status()?
513 .text()
514 .await?;
515 let response_map: Option<HashMap<String, serde_json::Value>> =
516 serde_json::from_str(&server_response_raw_json).ok();
517 Ok(response_map.is_some_and(|response_map| {
518 response_map.contains_key("module") && response_map.contains_key("version")
519 }))
520 }
521}
522
523#[cfg(test)]
524mod test {
525 use super::*;
526
527 #[test]
528 fn test_deserialize_tfa_success() {
529 let response_str = r#"{
530 "success": true,
531 "expires": 1782939987,
532 "tfa_token": "dummy_token"
533}
534"#;
535 let result = serde_json::from_str(response_str).unwrap();
536 assert!(matches!(
537 result,
538 LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess { .. })
539 ));
540 }
541
542 #[test]
543 fn test_deserialize_tfa_failure() {
544 let response_str = r#"{
545 "errors": [
546 "Invalid verification code"
547 ]
548}
549"#;
550 let result = serde_json::from_str(response_str).unwrap();
551 assert!(matches!(
552 result,
553 LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { .. })
554 ));
555 }
556
557 #[test]
558 fn test_deserialize_luarocks_error_response() {
559 let response_str = r#"{
560 "errors": [
561 "Invalid verification code"
562 ]
563}
564"#;
565 let result = serde_json::from_str(response_str).unwrap();
566 assert!(matches!(result, LuarocksErrorResponse { .. }));
567 }
568}