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