1use std::{env, io};
2
3use crate::operations::SearchAndDownloadError;
4use crate::package::SpecRevIterator;
5use crate::package_db::PackageDB;
6use crate::project::project_toml::{ProjectTomlError, RemoteProjectTomlValidationError};
7use crate::rockspec::Rockspec;
8use crate::TOOL_VERSION;
9use crate::{config::Config, project::Project};
10
11use bon::Builder;
12use itertools::Itertools;
13use miette::Diagnostic;
14use reqwest::multipart::{Form, Part};
15use reqwest::StatusCode;
16use serde::Deserialize;
17use serde_enum_str::Serialize_enum_str;
18use thiserror::Error;
19use url::Url;
20
21#[cfg(feature = "gpgme")]
22use gpgme::{Context, Data};
23#[cfg(feature = "gpgme")]
24use std::io::Read;
25
26const TFA_TOKEN_HEADER: &str = "X-TFA-Token";
27
28#[derive(Builder)]
31#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
32pub struct ProjectUpload<'a> {
33 project: &'a Project,
34 api_key: Option<ApiKey>,
35 tfa_code: Option<String>,
36 #[cfg(feature = "gpgme")]
37 sign_protocol: SignatureProtocol,
38 config: &'a Config,
39 package_db: &'a PackageDB,
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, Diagnostic)]
59#[non_exhaustive]
60pub enum ToolCheckError {
61 #[error("error parsing tool check URL")]
62 #[diagnostic(help("check your server configuration"))]
63 ParseError(#[from] url::ParseError),
64 #[error("error sending HTTP request")]
65 #[diagnostic(help(
66 r#"check your network connection and server configuration.
67if the issue persists, the server may be temporarily unavailable."#
68 ))]
69 Request(#[from] reqwest::Error),
70 #[error(r#"Lux is out of date with {0}'s expected tool version.
71 Lux is at version {TOOL_VERSION}, server is at {server_version}"#, server_version = _1.version)]
72 #[diagnostic(help("ensure you have an up-to-date version of Lux installed"))]
73 ToolOutdated(String, VersionCheckResponse),
74}
75
76#[derive(Error, Debug, Diagnostic)]
77#[non_exhaustive]
78pub enum UserCheckError {
79 #[error("error parsing user check URL")]
80 #[diagnostic(help("check your server configuration"))]
81 Parse(#[from] url::ParseError),
82 #[error(transparent)]
83 #[diagnostic(help(
84 r#"check your network connection and server configuration.
85if the issue persists, the server may be temporarily unavailable."#
86 ))]
87 Request(#[from] reqwest::Error),
88 #[diagnostic(help(
89 "ensure that the API key provided via the $LUX_API_KEY environemnt variable is correct"
90 ))]
91 #[error("invalid API key provided")]
92 UserNotFound,
93 #[error("server '{0}' responded with error status: '{1}'")]
94 #[diagnostic(help("the server may be temporarily unavailable"))]
95 Server(Url, StatusCode),
96}
97
98#[derive(Error, Debug, Diagnostic)]
99#[non_exhaustive]
100pub enum RockCheckError {
101 #[error("parse error while checking rock status on server")]
102 #[diagnostic(help("check your server configuration"))]
103 ParseError(#[from] url::ParseError),
104 #[error("HTTP request error while checking rock status on server")]
105 #[diagnostic(help(
106 r#"check your network connection and server configuration.
107if the issue persists, the server may be temporarily unavailable."#
108 ))]
109 Request(#[from] reqwest::Error),
110}
111
112#[derive(Error, Debug, Diagnostic)]
113#[non_exhaustive]
114#[error(transparent)]
115pub enum UploadError {
116 #[error("error parsing upload URL")]
117 #[diagnostic(help("check your server configuration"))]
118 ParseError(#[from] url::ParseError),
119 #[error("HTPP request error while uploading")]
120 #[diagnostic(help(
121 r#"check your network connection and server configuration.
122if the issue persists, the server may be temporarily unavailable."#
123 ))]
124 Request(#[from] reqwest::Error),
125 #[error("server '{0}' responded with error status: '{1}'")]
126 #[diagnostic(help("the server may be temporarily unavailable"))]
127 Server(Url, StatusCode),
128 #[error("client error when requesting {0}:\n{1}")]
129 #[diagnostic(help(
130 r#"check your network connection and server configuration.
131if the issue persists, the server may be temporarily unavailable."#
132 ))]
133 Client(Url, String),
134 #[diagnostic(transparent)]
135 RockCheck(#[from] RockCheckError),
136 #[error("a package with the same rockspec content already exists on the server: '{0}'")]
137 #[diagnostic(help("try uploading a rockspec with a different version"))]
138 RockExists(Url),
139 #[error("unable to read rockspec")]
140 #[diagnostic(help("check that the rockspec exists and is readable"))]
141 RockspecRead(#[from] std::io::Error),
142 #[cfg(feature = "gpgme")]
143 #[error("GPG signing failed")]
144 #[diagnostic(help(
145 r#"ensure that a GPG agent is running and that a valid GPG signing key is registered.
146 if you'd like to skip the signing step, supply `--sign-protocol=none`
147 "#
148 ))]
149 Signature(#[from] gpgme::Error),
150 #[error(transparent)]
151 #[diagnostic(transparent)]
152 ToolCheck(#[from] ToolCheckError),
153 #[error(transparent)]
154 #[diagnostic(transparent)]
155 UserCheck(#[from] UserCheckError),
156 #[error(transparent)]
157 #[diagnostic(transparent)]
158 ApiKeyUnspecified(#[from] ApiKeyUnspecified),
159 #[error(transparent)]
160 #[diagnostic(transparent)]
161 ValidationError(#[from] RemoteProjectTomlValidationError),
162 #[error("unsupported version: '{0}'")]
163 #[diagnostic(help("Lux can upload packages with a SemVer version, 'dev' or 'scm'"))]
164 UnsupportedVersion(String),
165 #[error(transparent)]
166 #[diagnostic(transparent)]
167 Rockspec(#[from] ProjectTomlError),
168 #[error("the maximum supported number of rockspec revisions per version has been exceeded")]
169 #[diagnostic(help("bump the version to a version that has not yet been published"))]
170 MaxSpecRevsExceeded,
171 #[error("rock already exists on server. Error downloading existing rockspec")]
172 #[diagnostic(forward(0))]
173 SearchAndDownload(#[from] Box<SearchAndDownloadError>),
174 #[error("error computing rockspec hash")]
175 Hash(io::Error),
176 #[error("the 2FA code '{0}' was rejected by the server: {1}")]
177 #[diagnostic(help("it may have expired; try again with a new code."))]
178 TfaCodeRejected(String, String),
179}
180
181impl From<SearchAndDownloadError> for UploadError {
182 fn from(source: SearchAndDownloadError) -> Self {
183 Self::SearchAndDownload(Box::new(source))
184 }
185}
186
187pub struct ApiKey(String);
188
189#[derive(Error, Debug, Diagnostic)]
190#[non_exhaustive]
191#[error("no API key provided")]
192#[diagnostic(help("please set the $LUX_API_KEY environment variable"))]
193pub struct ApiKeyUnspecified;
194
195impl ApiKey {
196 pub fn new() -> Result<Self, ApiKeyUnspecified> {
199 Ok(Self(
200 env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
201 ))
202 }
203
204 pub fn from(str: &str) -> Self {
214 Self(str.to_string())
215 }
216
217 pub unsafe fn get(&self) -> &str {
224 &self.0
225 }
226}
227
228struct TfaToken(String);
232
233impl TfaToken {
234 unsafe fn get(&self) -> &str {
241 &self.0
242 }
243}
244
245impl<'de> Deserialize<'de> for TfaToken {
246 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
247 where
248 D: serde::Deserializer<'de>,
249 {
250 String::deserialize(deserializer).map(Self)
251 }
252}
253
254#[derive(Deserialize)]
255struct LuarocksTfaVerificationSuccess {
256 tfa_token: TfaToken,
257}
258
259#[derive(Deserialize)]
261#[serde(untagged)]
262enum LuarocksTfaVerificationResponse {
263 Success(LuarocksTfaVerificationSuccess),
264 Failure(LuarocksErrorResponse),
265}
266
267#[derive(Deserialize)]
268struct LuarocksErrorResponse {
269 errors: Vec<String>,
270}
271
272#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
273#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
274#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
275#[serde(rename_all = "lowercase")]
276#[derive(Default)]
277#[cfg(not(feature = "gpgme"))]
278pub enum SignatureProtocol {
279 #[default]
280 None,
281}
282
283#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
284#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
285#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
286#[serde(rename_all = "lowercase")]
287#[derive(Default)]
288#[cfg(feature = "gpgme")]
289pub enum SignatureProtocol {
290 None,
291 Assuan,
292 CMS,
293 #[default]
294 Default,
295 G13,
296 GPGConf,
297 OpenPGP,
298 Spawn,
299 UIServer,
300}
301
302#[cfg(feature = "gpgme")]
303impl From<SignatureProtocol> for gpgme::Protocol {
304 fn from(val: SignatureProtocol) -> Self {
305 match val {
306 SignatureProtocol::Default => gpgme::Protocol::Default,
307 SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
308 SignatureProtocol::CMS => gpgme::Protocol::Cms,
309 SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
310 SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
311 SignatureProtocol::G13 => gpgme::Protocol::G13,
312 SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
313 SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
314 SignatureProtocol::None => unreachable!(),
315 }
316 }
317}
318
319#[tracing::instrument(
320 name = "Uploading",
321 level = "info",
322 skip_all,
323 fields(project = args.project.toml().package().to_string()),
324)]
325async fn upload_from_project(args: ProjectUpload<'_>) -> Result<(), UploadError> {
326 let project = args.project;
327 let api_key = args.api_key.unwrap_or(ApiKey::new()?);
328 #[cfg(feature = "gpgme")]
329 let protocol = args.sign_protocol;
330 let config = args.config;
331 let package_db = args.package_db;
332
333 let client = crate::reqwest::https_client(args.config)?;
334
335 helpers::ensure_tool_version(client, config.server()).await?;
336 helpers::ensure_user_exists(client, &api_key, config.server()).await?;
337
338 let (rockspec, rockspec_content) =
339 helpers::generate_rockspec(project, client, &api_key, config, package_db).await?;
340
341 #[cfg(not(feature = "gpgme"))]
342 let signed: Option<String> = None;
343
344 #[cfg(feature = "gpgme")]
345 let signed = if let SignatureProtocol::None = protocol {
346 None
347 } else {
348 let mut ctx = Context::from_protocol(protocol.into())?;
349 let mut signature = Data::new()?;
350
351 ctx.set_armor(true);
352 ctx.sign_detached(rockspec_content.clone(), &mut signature)?;
353
354 let mut signature_str = String::new();
355 signature.read_to_string(&mut signature_str)?;
356
357 Some(signature_str)
358 };
359
360 let rockspec = Part::text(rockspec_content)
361 .file_name(format!(
362 "{}-{}.rockspec",
363 rockspec.package(),
364 rockspec.version()
365 ))
366 .mime_str("application/octet-stream")?;
367
368 let multipart = {
369 let multipart = Form::new().part("rockspec_file", rockspec);
370
371 match signed {
372 Some(signature) => {
373 let part = Part::text(signature).file_name("project.rockspec.sig");
374 multipart.part("rockspec_sig", part)
375 }
376 None => multipart,
377 }
378 };
379
380 let mut request = client
381 .post(unsafe { helpers::url_for_method(config.server(), &api_key, "upload")? })
382 .multipart(multipart);
383
384 if let Some(code) = args.tfa_code {
385 let token = helpers::verify_tfa_code(client, config.server(), &api_key, &code).await?;
386 request = request.header(TFA_TOKEN_HEADER, unsafe { token.get() });
387 }
388
389 let response = request.send().await?;
390
391 let status = response.status();
392 if status.is_server_error() {
393 Err(UploadError::Server(config.server().clone(), status))
394 } else if status.is_success() {
395 Ok(())
396 } else {
397 let response = response.json::<LuarocksErrorResponse>().await?;
398 let errors = response.errors.into_iter().join("\n");
399 Err(UploadError::Client(config.server().clone(), errors))
400 }
401}
402
403mod helpers {
404 use std::collections::HashMap;
405
406 use super::*;
407 use crate::hash::HasIntegrity;
408 use crate::operations::Download;
409 use crate::package::{PackageName, PackageSpec, PackageVersion};
410 use crate::project::project_toml::RemoteProjectToml;
411 use crate::upload::RockCheckError;
412 use crate::upload::{ToolCheckError, UserCheckError};
413 use itertools::Itertools;
414 use reqwest::Client;
415 use ssri::Integrity;
416 use url::Url;
417
418 pub(crate) unsafe fn url_for_method(
423 server_url: &Url,
424 api_key: &ApiKey,
425 endpoint: &str,
426 ) -> Result<Url, url::ParseError> {
427 server_url
428 .join("api/1/")?
429 .join(&format!("{}/", api_key.get()))?
430 .join(endpoint)
431 }
432
433 #[tracing::instrument(level = "trace", skip(client))]
434 pub(crate) async fn ensure_tool_version(
435 client: &Client,
436 server_url: &Url,
437 ) -> Result<(), ToolCheckError> {
438 let url = server_url.join("api/tool_version")?;
439 let response: VersionCheckResponse = client
440 .post(url)
441 .json(&("current", TOOL_VERSION))
442 .send()
443 .await?
444 .json()
445 .await?;
446
447 if response.version == TOOL_VERSION {
448 Ok(())
449 } else {
450 Err(ToolCheckError::ToolOutdated(
451 server_url.to_string(),
452 response,
453 ))
454 }
455 }
456
457 #[tracing::instrument(level = "trace", skip(client, api_key))]
458 pub(crate) async fn verify_tfa_code(
459 client: &Client,
460 server_url: &Url,
461 api_key: &ApiKey,
462 tfa_code: &str,
463 ) -> Result<TfaToken, UploadError> {
464 let response = client
465 .get(unsafe { url_for_method(server_url, api_key, "verify_tfa")? })
466 .query(&("code", tfa_code.to_string()))
467 .send()
468 .await?;
469 let status = response.status();
470 if status.is_server_error() {
471 Err(UploadError::Server(server_url.clone(), status))
472 } else {
473 match response.json::<LuarocksTfaVerificationResponse>().await? {
474 LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess {
475 tfa_token,
476 }) => Ok(tfa_token),
477 LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { errors }) => {
478 Err(UploadError::TfaCodeRejected(
479 tfa_code.to_string(),
480 errors.into_iter().join("\n"),
481 ))
482 }
483 }
484 }
485 }
486
487 #[tracing::instrument(level = "trace", skip(client, api_key))]
488 pub(crate) async fn ensure_user_exists(
489 client: &Client,
490 api_key: &ApiKey,
491 server_url: &Url,
492 ) -> Result<(), UserCheckError> {
493 let response = client
494 .get(unsafe { url_for_method(server_url, api_key, "status")? })
495 .send()
496 .await?;
497 let status = response.status();
498 if status.is_client_error() {
499 Err(UserCheckError::UserNotFound)
500 } else if status.is_server_error() {
501 Err(UserCheckError::Server(server_url.clone(), status))
502 } else {
503 Ok(())
504 }
505 }
506
507 #[tracing::instrument(level = "trace", skip_all)]
508 pub(crate) async fn generate_rockspec(
509 project: &Project,
510 client: &Client,
511 api_key: &ApiKey,
512 config: &Config,
513 package_db: &PackageDB,
514 ) -> Result<(RemoteProjectToml, String), UploadError> {
515 for specrev in SpecRevIterator::new() {
516 let rockspec = project.toml().into_remote(Some(specrev))?;
517
518 let rockspec_content = rockspec.to_lua_remote_rockspec_string()?;
519
520 if let PackageVersion::StringVer(ver) = rockspec.version() {
521 return Err(UploadError::UnsupportedVersion(ver.to_string()));
522 }
523 if helpers::rock_exists(
524 client,
525 api_key,
526 rockspec.package(),
527 rockspec.version(),
528 config.server(),
529 )
530 .await?
531 {
532 let package =
533 PackageSpec::new(rockspec.package().clone(), rockspec.version().clone());
534 let existing_rockspec = Download::new(&package.into(), config)
535 .package_db(package_db)
536 .download_rockspec()
537 .await?
538 .rockspec;
539 let existing_rockspec_hash =
540 existing_rockspec.hash().await.map_err(UploadError::Hash)?;
541 let rockspec_content_hash = Integrity::from(&rockspec_content);
542 if existing_rockspec_hash
543 .matches(&rockspec_content_hash)
544 .is_some()
545 {
546 return Err(UploadError::RockExists(config.server().clone()));
547 }
548 } else {
549 return Ok((rockspec, rockspec_content));
550 }
551 }
552 Err(UploadError::MaxSpecRevsExceeded)
553 }
554
555 #[tracing::instrument(level = "trace", skip(client, api_key))]
556 async fn rock_exists(
557 client: &Client,
558 api_key: &ApiKey,
559 name: &PackageName,
560 version: &PackageVersion,
561 server: &Url,
562 ) -> Result<bool, RockCheckError> {
563 let server_response_raw_json = client
564 .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
565 .query(&(
566 ("package", name.to_string()),
567 ("version", version.to_string()),
568 ))
569 .send()
570 .await?
571 .error_for_status()?
572 .text()
573 .await?;
574 let response_map: Option<HashMap<String, serde_json::Value>> =
575 serde_json::from_str(&server_response_raw_json).ok();
576 Ok(response_map.is_some_and(|response_map| {
577 response_map.contains_key("module") && response_map.contains_key("version")
578 }))
579 }
580}
581
582#[cfg(test)]
583mod test {
584 use super::*;
585
586 #[test]
587 fn test_deserialize_tfa_success() {
588 let response_str = r#"{
589 "success": true,
590 "expires": 1782939987,
591 "tfa_token": "dummy_token"
592}
593"#;
594 let result = serde_json::from_str(response_str).unwrap();
595 assert!(matches!(
596 result,
597 LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess { .. })
598 ));
599 }
600
601 #[test]
602 fn test_deserialize_tfa_failure() {
603 let response_str = r#"{
604 "errors": [
605 "Invalid verification code"
606 ]
607}
608"#;
609 let result = serde_json::from_str(response_str).unwrap();
610 assert!(matches!(
611 result,
612 LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { .. })
613 ));
614 }
615
616 #[test]
617 fn test_deserialize_luarocks_error_response() {
618 let response_str = r#"{
619 "errors": [
620 "Invalid verification code"
621 ]
622}
623"#;
624 let result = serde_json::from_str(response_str).unwrap();
625 assert!(matches!(result, LuarocksErrorResponse { .. }));
626 }
627}