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