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