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 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 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, Diagnostic)]
59#[non_exhaustive]
60pub enum ToolCheckError {
61 #[error("error parsing tool check URL:\n{0}")]
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("{0}")] #[diagnostic(help("check the rockspec or lux.toml for valid syntax and make sure it matches the specification."),)]
167 Rockspec(String),
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] 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
181pub struct ApiKey(String);
182
183#[derive(Error, Debug, Diagnostic)]
184#[non_exhaustive]
185#[error("no API key provided")]
186#[diagnostic(help("please set the $LUX_API_KEY environment variable"))]
187pub struct ApiKeyUnspecified;
188
189impl ApiKey {
190 pub fn new() -> Result<Self, ApiKeyUnspecified> {
193 Ok(Self(
194 env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
195 ))
196 }
197
198 pub fn from(str: &str) -> Self {
208 Self(str.to_string())
209 }
210
211 pub unsafe fn get(&self) -> &str {
218 &self.0
219 }
220}
221
222struct TfaToken(String);
226
227impl TfaToken {
228 unsafe fn get(&self) -> &str {
235 &self.0
236 }
237}
238
239impl<'de> Deserialize<'de> for TfaToken {
240 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
241 where
242 D: serde::Deserializer<'de>,
243 {
244 String::deserialize(deserializer).map(Self)
245 }
246}
247
248#[derive(Deserialize)]
249struct LuarocksTfaVerificationSuccess {
250 tfa_token: TfaToken,
251}
252
253#[derive(Deserialize)]
255#[serde(untagged)]
256enum LuarocksTfaVerificationResponse {
257 Success(LuarocksTfaVerificationSuccess),
258 Failure(LuarocksErrorResponse),
259}
260
261#[derive(Deserialize)]
262struct LuarocksErrorResponse {
263 errors: Vec<String>,
264}
265
266#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
267#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
268#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
269#[serde(rename_all = "lowercase")]
270#[derive(Default)]
271#[cfg(not(feature = "gpgme"))]
272pub enum SignatureProtocol {
273 #[default]
274 None,
275}
276
277#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
278#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
279#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
280#[serde(rename_all = "lowercase")]
281#[derive(Default)]
282#[cfg(feature = "gpgme")]
283pub enum SignatureProtocol {
284 None,
285 Assuan,
286 CMS,
287 #[default]
288 Default,
289 G13,
290 GPGConf,
291 OpenPGP,
292 Spawn,
293 UIServer,
294}
295
296#[cfg(feature = "gpgme")]
297impl From<SignatureProtocol> for gpgme::Protocol {
298 fn from(val: SignatureProtocol) -> Self {
299 match val {
300 SignatureProtocol::Default => gpgme::Protocol::Default,
301 SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
302 SignatureProtocol::CMS => gpgme::Protocol::Cms,
303 SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
304 SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
305 SignatureProtocol::G13 => gpgme::Protocol::G13,
306 SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
307 SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
308 SignatureProtocol::None => unreachable!(),
309 }
310 }
311}
312
313#[tracing::instrument(
314 name = "Uploading",
315 level = "info",
316 skip_all,
317 fields(project = args.project.toml().package().to_string()),
318)]
319async fn upload_from_project(args: ProjectUpload<'_>) -> Result<(), UploadError> {
320 let project = args.project;
321 let api_key = args.api_key.unwrap_or(ApiKey::new()?);
322 #[cfg(feature = "gpgme")]
323 let protocol = args.sign_protocol;
324 let config = args.config;
325 let package_db = args.package_db;
326
327 let client = crate::reqwest::https_client(args.config)?;
328
329 helpers::ensure_tool_version(client, config.server()).await?;
330 helpers::ensure_user_exists(client, &api_key, config.server()).await?;
331
332 let (rockspec, rockspec_content) =
333 helpers::generate_rockspec(project, client, &api_key, config, package_db).await?;
334
335 #[cfg(not(feature = "gpgme"))]
336 let signed: Option<String> = None;
337
338 #[cfg(feature = "gpgme")]
339 let signed = if let SignatureProtocol::None = protocol {
340 None
341 } else {
342 let mut ctx = Context::from_protocol(protocol.into())?;
343 let mut signature = Data::new()?;
344
345 ctx.set_armor(true);
346 ctx.sign_detached(rockspec_content.clone(), &mut signature)?;
347
348 let mut signature_str = String::new();
349 signature.read_to_string(&mut signature_str)?;
350
351 Some(signature_str)
352 };
353
354 let rockspec = Part::text(rockspec_content)
355 .file_name(format!(
356 "{}-{}.rockspec",
357 rockspec.package(),
358 rockspec.version()
359 ))
360 .mime_str("application/octet-stream")?;
361
362 let multipart = {
363 let multipart = Form::new().part("rockspec_file", rockspec);
364
365 match signed {
366 Some(signature) => {
367 let part = Part::text(signature).file_name("project.rockspec.sig");
368 multipart.part("rockspec_sig", part)
369 }
370 None => multipart,
371 }
372 };
373
374 let mut request = client
375 .post(unsafe { helpers::url_for_method(config.server(), &api_key, "upload")? })
376 .multipart(multipart);
377
378 if let Some(code) = args.tfa_code {
379 let token = helpers::verify_tfa_code(client, config.server(), &api_key, &code).await?;
380 request = request.header(TFA_TOKEN_HEADER, unsafe { token.get() });
381 }
382
383 let response = request.send().await?;
384
385 let status = response.status();
386 if status.is_server_error() {
387 Err(UploadError::Server(config.server().clone(), status))
388 } else if status.is_success() {
389 Ok(())
390 } else {
391 let response = response.json::<LuarocksErrorResponse>().await?;
392 let errors = response.errors.into_iter().join("\n");
393 Err(UploadError::Client(config.server().clone(), errors))
394 }
395}
396
397mod helpers {
398 use std::collections::HashMap;
399
400 use super::*;
401 use crate::hash::HasIntegrity;
402 use crate::operations::Download;
403 use crate::package::{PackageName, PackageSpec, PackageVersion};
404 use crate::project::project_toml::RemoteProjectToml;
405 use crate::upload::RockCheckError;
406 use crate::upload::{ToolCheckError, UserCheckError};
407 use itertools::Itertools;
408 use reqwest::Client;
409 use ssri::Integrity;
410 use url::Url;
411
412 pub(crate) unsafe fn url_for_method(
417 server_url: &Url,
418 api_key: &ApiKey,
419 endpoint: &str,
420 ) -> Result<Url, url::ParseError> {
421 server_url
422 .join("api/1/")?
423 .join(&format!("{}/", api_key.get()))?
424 .join(endpoint)
425 }
426
427 #[tracing::instrument(level = "trace", skip(client))]
428 pub(crate) async fn ensure_tool_version(
429 client: &Client,
430 server_url: &Url,
431 ) -> Result<(), ToolCheckError> {
432 let url = server_url.join("api/tool_version")?;
433 let response: VersionCheckResponse = client
434 .post(url)
435 .json(&("current", TOOL_VERSION))
436 .send()
437 .await?
438 .json()
439 .await?;
440
441 if response.version == TOOL_VERSION {
442 Ok(())
443 } else {
444 Err(ToolCheckError::ToolOutdated(
445 server_url.to_string(),
446 response,
447 ))
448 }
449 }
450
451 #[tracing::instrument(level = "trace", skip(client, api_key))]
452 pub(crate) async fn verify_tfa_code(
453 client: &Client,
454 server_url: &Url,
455 api_key: &ApiKey,
456 tfa_code: &str,
457 ) -> Result<TfaToken, UploadError> {
458 let response = client
459 .get(unsafe { url_for_method(server_url, api_key, "verify_tfa")? })
460 .query(&("code", tfa_code.to_string()))
461 .send()
462 .await?;
463 let status = response.status();
464 if status.is_server_error() {
465 Err(UploadError::Server(server_url.clone(), status))
466 } else {
467 match response.json::<LuarocksTfaVerificationResponse>().await? {
468 LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess {
469 tfa_token,
470 }) => Ok(tfa_token),
471 LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { errors }) => {
472 Err(UploadError::TfaCodeRejected(
473 tfa_code.to_string(),
474 errors.into_iter().join("\n"),
475 ))
476 }
477 }
478 }
479 }
480
481 #[tracing::instrument(level = "trace", skip(client, api_key))]
482 pub(crate) async fn ensure_user_exists(
483 client: &Client,
484 api_key: &ApiKey,
485 server_url: &Url,
486 ) -> Result<(), UserCheckError> {
487 let response = client
488 .get(unsafe { url_for_method(server_url, api_key, "status")? })
489 .send()
490 .await?;
491 let status = response.status();
492 if status.is_client_error() {
493 Err(UserCheckError::UserNotFound)
494 } else if status.is_server_error() {
495 Err(UserCheckError::Server(server_url.clone(), status))
496 } else {
497 Ok(())
498 }
499 }
500
501 #[tracing::instrument(level = "trace", skip_all)]
502 pub(crate) async fn generate_rockspec(
503 project: &Project,
504 client: &Client,
505 api_key: &ApiKey,
506 config: &Config,
507 package_db: &RemotePackageDB,
508 ) -> Result<(RemoteProjectToml, String), UploadError> {
509 for specrev in SpecRevIterator::new() {
510 let rockspec = project.toml().into_remote(Some(specrev))?;
511
512 let rockspec_content = rockspec
513 .to_lua_remote_rockspec_string()
514 .map_err(|err| UploadError::Rockspec(err.to_string()))?;
515
516 if let PackageVersion::StringVer(ver) = rockspec.version() {
517 return Err(UploadError::UnsupportedVersion(ver.to_string()));
518 }
519 if helpers::rock_exists(
520 client,
521 api_key,
522 rockspec.package(),
523 rockspec.version(),
524 config.server(),
525 )
526 .await?
527 {
528 let package =
529 PackageSpec::new(rockspec.package().clone(), rockspec.version().clone());
530 let existing_rockspec = Download::new(&package.into(), config)
531 .package_db(package_db)
532 .download_rockspec()
533 .await?
534 .rockspec;
535 let existing_rockspec_hash =
536 existing_rockspec.hash().await.map_err(UploadError::Hash)?;
537 let rockspec_content_hash = Integrity::from(&rockspec_content);
538 if existing_rockspec_hash
539 .matches(&rockspec_content_hash)
540 .is_some()
541 {
542 return Err(UploadError::RockExists(config.server().clone()));
543 }
544 } else {
545 return Ok((rockspec, rockspec_content));
546 }
547 }
548 Err(UploadError::MaxSpecRevsExceeded)
549 }
550
551 #[tracing::instrument(level = "trace", skip(client, api_key))]
552 async fn rock_exists(
553 client: &Client,
554 api_key: &ApiKey,
555 name: &PackageName,
556 version: &PackageVersion,
557 server: &Url,
558 ) -> Result<bool, RockCheckError> {
559 let server_response_raw_json = client
560 .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
561 .query(&(
562 ("package", name.to_string()),
563 ("version", version.to_string()),
564 ))
565 .send()
566 .await?
567 .error_for_status()?
568 .text()
569 .await?;
570 let response_map: Option<HashMap<String, serde_json::Value>> =
571 serde_json::from_str(&server_response_raw_json).ok();
572 Ok(response_map.is_some_and(|response_map| {
573 response_map.contains_key("module") && response_map.contains_key("version")
574 }))
575 }
576}
577
578#[cfg(test)]
579mod test {
580 use super::*;
581
582 #[test]
583 fn test_deserialize_tfa_success() {
584 let response_str = r#"{
585 "success": true,
586 "expires": 1782939987,
587 "tfa_token": "dummy_token"
588}
589"#;
590 let result = serde_json::from_str(response_str).unwrap();
591 assert!(matches!(
592 result,
593 LuarocksTfaVerificationResponse::Success(LuarocksTfaVerificationSuccess { .. })
594 ));
595 }
596
597 #[test]
598 fn test_deserialize_tfa_failure() {
599 let response_str = r#"{
600 "errors": [
601 "Invalid verification code"
602 ]
603}
604"#;
605 let result = serde_json::from_str(response_str).unwrap();
606 assert!(matches!(
607 result,
608 LuarocksTfaVerificationResponse::Failure(LuarocksErrorResponse { .. })
609 ));
610 }
611
612 #[test]
613 fn test_deserialize_luarocks_error_response() {
614 let response_str = r#"{
615 "errors": [
616 "Invalid verification code"
617 ]
618}
619"#;
620 let result = serde_json::from_str(response_str).unwrap();
621 assert!(matches!(result, LuarocksErrorResponse { .. }));
622 }
623}