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 reqwest::multipart::{Form, Part};
14use reqwest::StatusCode;
15use serde::Deserialize;
16use serde_enum_str::Serialize_enum_str;
17use thiserror::Error;
18use url::Url;
19
20#[cfg(feature = "gpgme")]
21use gpgme::{Context, Data};
22#[cfg(feature = "gpgme")]
23use std::io::Read;
24
25#[derive(Builder)]
28#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
29pub struct ProjectUpload<'a> {
30 project: &'a Project,
31 api_key: Option<ApiKey>,
32 #[cfg(feature = "gpgme")]
33 sign_protocol: SignatureProtocol,
34 config: &'a Config,
35 progress: &'a Progress<ProgressBar>,
36 package_db: &'a RemotePackageDB,
37}
38
39impl<State> ProjectUploadBuilder<'_, State>
40where
41 State: project_upload_builder::State + project_upload_builder::IsComplete,
42{
43 pub async fn upload_to_luarocks(self) -> Result<(), UploadError> {
45 let args = self._build();
46 upload_from_project(args).await
47 }
48}
49
50#[derive(Deserialize, Debug)]
51pub struct VersionCheckResponse {
52 version: String,
53}
54
55#[derive(Error, Debug)]
56pub enum ToolCheckError {
57 #[error("error parsing tool check URL:\n{0}")]
58 ParseError(#[from] url::ParseError),
59 #[error("error sending HTTP request:\n{0}")]
60 Request(#[from] reqwest::Error),
61 #[error(r#"`lux` is out of date with {0}'s expected tool version.
62 `lux` is at version {TOOL_VERSION}, server is at {server_version}"#, server_version = _1.version)]
63 ToolOutdated(String, VersionCheckResponse),
64}
65
66#[derive(Error, Debug)]
67pub enum UserCheckError {
68 #[error("error parsing user check URL:\n{0}")]
69 ParseError(#[from] url::ParseError),
70 #[error(transparent)]
71 Request(#[from] reqwest::Error),
72 #[error("invalid API key provided")]
73 UserNotFound,
74 #[error("server {0} responded with error status: {1}")]
75 Server(Url, StatusCode),
76}
77
78#[derive(Error, Debug)]
79pub enum RockCheckError {
80 #[error("parse error while checking rock status on server:\n{0}")]
81 ParseError(#[from] url::ParseError),
82 #[error("HTTP request error while checking rock status on server:\n{0}")]
83 Request(#[from] reqwest::Error),
84}
85
86#[derive(Error, Debug)]
87#[error(transparent)]
88pub enum UploadError {
89 #[error("error parsing upload URL:\n{0}")]
90 ParseError(#[from] url::ParseError),
91 #[error("HTPP request error while uploading:\n{0}")]
92 Request(#[from] reqwest::Error),
93 #[error("server {0} responded with error status: {1}")]
94 Server(Url, StatusCode),
95 #[error("client error when requesting {0}\nStatus code: {1}")]
96 Client(Url, StatusCode),
97 RockCheck(#[from] RockCheckError),
98 #[error("a package with the same rockspec content already exists on the server: {0}")]
99 RockExists(Url),
100 #[error("unable to read rockspec: {0}")]
101 RockspecRead(#[from] std::io::Error),
102 #[cfg(feature = "gpgme")]
103 #[error(
104 r#"{0}.
105
106 HINT: Please ensure that a GPG agent is running and that a valid GPG signing key is registered.
107 If you'd like to skip the signing step, supply `--sign-protocol none`
108 "#
109 )]
110 Signature(#[from] gpgme::Error),
111 ToolCheck(#[from] ToolCheckError),
112 UserCheck(#[from] UserCheckError),
113 ApiKeyUnspecified(#[from] ApiKeyUnspecified),
114 ValidationError(#[from] RemoteProjectTomlValidationError),
115 #[error(
116 "unsupported version: `{0}`.\nLux can upload packages with a SemVer version, 'dev' or 'scm'"
117 )]
118 UnsupportedVersion(String),
119 #[error("{0}")] Rockspec(String),
121 #[error("the maximum supported number of rockspec revisions per version has been exceeded")]
122 MaxSpecRevsExceeded,
123 #[error("rock already exists on server. Error downloading existing rockspec:\n{0}")]
124 SearchAndDownload(#[from] SearchAndDownloadError),
125 #[error("error computing rockspec hash:\n{0}")]
126 Hash(io::Error),
127}
128
129pub struct ApiKey(String);
130
131#[derive(Error, Debug)]
132#[error("no API key provided! Please set the $LUX_API_KEY environment variable")]
133pub struct ApiKeyUnspecified;
134
135impl ApiKey {
136 pub fn new() -> Result<Self, ApiKeyUnspecified> {
139 Ok(Self(
140 env::var("LUX_API_KEY").map_err(|_| ApiKeyUnspecified)?,
141 ))
142 }
143
144 pub unsafe fn from(str: String) -> Self {
154 Self(str)
155 }
156
157 pub unsafe fn get(&self) -> &String {
164 &self.0
165 }
166}
167
168#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
169#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
170#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
171#[serde(rename_all = "lowercase")]
172#[derive(Default)]
173#[cfg(not(feature = "gpgme"))]
174pub enum SignatureProtocol {
175 #[default]
176 None,
177}
178
179#[derive(Serialize_enum_str, Clone, PartialEq, Eq)]
180#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
181#[cfg_attr(feature = "clap", clap(rename_all = "lowercase"))]
182#[serde(rename_all = "lowercase")]
183#[derive(Default)]
184#[cfg(feature = "gpgme")]
185pub enum SignatureProtocol {
186 None,
187 Assuan,
188 CMS,
189 #[default]
190 Default,
191 G13,
192 GPGConf,
193 OpenPGP,
194 Spawn,
195 UIServer,
196}
197
198#[cfg(feature = "gpgme")]
199impl From<SignatureProtocol> for gpgme::Protocol {
200 fn from(val: SignatureProtocol) -> Self {
201 match val {
202 SignatureProtocol::Default => gpgme::Protocol::Default,
203 SignatureProtocol::OpenPGP => gpgme::Protocol::OpenPgp,
204 SignatureProtocol::CMS => gpgme::Protocol::Cms,
205 SignatureProtocol::GPGConf => gpgme::Protocol::GpgConf,
206 SignatureProtocol::Assuan => gpgme::Protocol::Assuan,
207 SignatureProtocol::G13 => gpgme::Protocol::G13,
208 SignatureProtocol::UIServer => gpgme::Protocol::UiServer,
209 SignatureProtocol::Spawn => gpgme::Protocol::Spawn,
210 SignatureProtocol::None => unreachable!(),
211 }
212 }
213}
214
215async fn upload_from_project(args: ProjectUpload<'_>) -> Result<(), UploadError> {
216 let project = args.project;
217 let api_key = args.api_key.unwrap_or(ApiKey::new()?);
218 #[cfg(feature = "gpgme")]
219 let protocol = args.sign_protocol;
220 let config = args.config;
221 let progress = args.progress;
222 let package_db = args.package_db;
223
224 let client = crate::reqwest::new_https_client(args.config)?;
225
226 helpers::ensure_tool_version(&client, config.server()).await?;
227 helpers::ensure_user_exists(&client, &api_key, config.server()).await?;
228
229 let (rockspec, rockspec_content) =
230 helpers::generate_rockspec(project, &client, &api_key, config, progress, package_db)
231 .await?;
232
233 #[cfg(not(feature = "gpgme"))]
234 let signed: Option<String> = None;
235
236 #[cfg(feature = "gpgme")]
237 let signed = if let SignatureProtocol::None = protocol {
238 None
239 } else {
240 let mut ctx = Context::from_protocol(protocol.into())?;
241 let mut signature = Data::new()?;
242
243 ctx.set_armor(true);
244 ctx.sign_detached(rockspec_content.clone(), &mut signature)?;
245
246 let mut signature_str = String::new();
247 signature.read_to_string(&mut signature_str)?;
248
249 Some(signature_str)
250 };
251
252 let rockspec = Part::text(rockspec_content)
253 .file_name(format!(
254 "{}-{}.rockspec",
255 rockspec.package(),
256 rockspec.version()
257 ))
258 .mime_str("application/octet-stream")?;
259
260 let multipart = {
261 let multipart = Form::new().part("rockspec_file", rockspec);
262
263 match signed {
264 Some(signature) => {
265 let part = Part::text(signature).file_name("project.rockspec.sig");
266 multipart.part("rockspec_sig", part)
267 }
268 None => multipart,
269 }
270 };
271
272 let response = client
273 .post(unsafe { helpers::url_for_method(config.server(), &api_key, "upload")? })
274 .multipart(multipart)
275 .send()
276 .await?;
277
278 let status = response.status();
279 if status.is_client_error() {
280 Err(UploadError::Client(config.server().clone(), status))
281 } else if status.is_server_error() {
282 Err(UploadError::Server(config.server().clone(), status))
283 } else {
284 Ok(())
285 }
286}
287
288mod helpers {
289 use std::collections::HashMap;
290
291 use super::*;
292 use crate::hash::HasIntegrity;
293 use crate::operations::Download;
294 use crate::package::{PackageName, PackageSpec, PackageVersion};
295 use crate::project::project_toml::RemoteProjectToml;
296 use crate::upload::RockCheckError;
297 use crate::upload::{ToolCheckError, UserCheckError};
298 use reqwest::Client;
299 use ssri::Integrity;
300 use url::Url;
301
302 pub(crate) unsafe fn url_for_method(
307 server_url: &Url,
308 api_key: &ApiKey,
309 endpoint: &str,
310 ) -> Result<Url, url::ParseError> {
311 server_url
312 .join("api/1/")?
313 .join(&format!("{}/", api_key.get()))?
314 .join(endpoint)
315 }
316
317 pub(crate) async fn ensure_tool_version(
318 client: &Client,
319 server_url: &Url,
320 ) -> Result<(), ToolCheckError> {
321 let url = server_url.join("api/tool_version")?;
322 let response: VersionCheckResponse = client
323 .post(url)
324 .json(&("current", TOOL_VERSION))
325 .send()
326 .await?
327 .json()
328 .await?;
329
330 if response.version == TOOL_VERSION {
331 Ok(())
332 } else {
333 Err(ToolCheckError::ToolOutdated(
334 server_url.to_string(),
335 response,
336 ))
337 }
338 }
339
340 pub(crate) async fn ensure_user_exists(
341 client: &Client,
342 api_key: &ApiKey,
343 server_url: &Url,
344 ) -> Result<(), UserCheckError> {
345 let response = client
346 .get(unsafe { url_for_method(server_url, api_key, "status")? })
347 .send()
348 .await?;
349 let status = response.status();
350 if status.is_client_error() {
351 Err(UserCheckError::UserNotFound)
352 } else if status.is_server_error() {
353 Err(UserCheckError::Server(server_url.clone(), status))
354 } else {
355 Ok(())
356 }
357 }
358
359 pub(crate) async fn generate_rockspec(
360 project: &Project,
361 client: &Client,
362 api_key: &ApiKey,
363 config: &Config,
364 progress: &Progress<ProgressBar>,
365 package_db: &RemotePackageDB,
366 ) -> Result<(RemoteProjectToml, String), UploadError> {
367 for specrev in SpecRevIterator::new() {
368 let rockspec = project.toml().into_remote(Some(specrev))?;
369
370 let rockspec_content = rockspec
371 .to_lua_remote_rockspec_string()
372 .map_err(|err| UploadError::Rockspec(err.to_string()))?;
373
374 if let PackageVersion::StringVer(ver) = rockspec.version() {
375 return Err(UploadError::UnsupportedVersion(ver.to_string()));
376 }
377 if helpers::rock_exists(
378 client,
379 api_key,
380 rockspec.package(),
381 rockspec.version(),
382 config.server(),
383 )
384 .await?
385 {
386 let package =
387 PackageSpec::new(rockspec.package().clone(), rockspec.version().clone());
388 let existing_rockspec = Download::new(&package.into(), config, progress)
389 .package_db(package_db)
390 .download_rockspec()
391 .await?
392 .rockspec;
393 let existing_rockspec_hash = existing_rockspec.hash().map_err(UploadError::Hash)?;
394 let rockspec_content_hash = Integrity::from(&rockspec_content);
395 if existing_rockspec_hash
396 .matches(&rockspec_content_hash)
397 .is_some()
398 {
399 return Err(UploadError::RockExists(config.server().clone()));
400 }
401 } else {
402 return Ok((rockspec, rockspec_content));
403 }
404 }
405 Err(UploadError::MaxSpecRevsExceeded)
406 }
407
408 async fn rock_exists(
409 client: &Client,
410 api_key: &ApiKey,
411 name: &PackageName,
412 version: &PackageVersion,
413 server: &Url,
414 ) -> Result<bool, RockCheckError> {
415 let server_response_raw_json = client
416 .get(unsafe { url_for_method(server, api_key, "check_rockspec")? })
417 .query(&(
418 ("package", name.to_string()),
419 ("version", version.to_string()),
420 ))
421 .send()
422 .await?
423 .error_for_status()?
424 .text()
425 .await?;
426 let response_map: Option<HashMap<String, serde_json::Value>> =
427 serde_json::from_str(&server_response_raw_json).ok();
428 Ok(response_map.is_some_and(|response_map| {
429 response_map.contains_key("module") && response_map.contains_key("version")
430 }))
431 }
432}