1use std::fs;
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::time::Duration;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use rns_core::msgpack::{self, Value};
9use rns_crypto::identity::Identity;
10use rns_crypto::sha256::sha256;
11
12use crate::client::{decode_status, SyncClient};
13use crate::config::ClientConfig;
14use crate::logging;
15use crate::protocol;
16use crate::util::{
17 default_reticulum_dir, default_rngit_dir, parse_hex_16, parse_rns_url_with_aliases,
18};
19use crate::{Error, Result};
20
21const RELEASE_TIMEOUT_SHORT: Duration = Duration::from_secs(120);
22const RELEASE_TIMEOUT_MEDIUM: Duration = Duration::from_secs(300);
23const RELEASE_TIMEOUT_LONG: Duration = Duration::from_secs(7200);
24
25pub fn main<I>(args: I) -> Result<()>
26where
27 I: IntoIterator<Item = String>,
28{
29 let options = ReleaseOptions::parse(args)?;
30 if let ReleaseCommand::Fetch {
31 target,
32 required_signer,
33 offline: true,
34 } = &options.command
35 {
36 let manifest_path = local_manifest_path(&options.remote).ok_or_else(|| {
37 Error::msg("Cannot perform offline verification without a local manifest")
38 })?;
39 return verify_release_manifest_offline(
40 &manifest_path,
41 target,
42 required_signer.as_deref(),
43 io::stdout(),
44 );
45 }
46 let rngit_dir = options.config_dir.clone().unwrap_or_else(default_rngit_dir);
47 let rns_dir = options
48 .rns_config_dir
49 .clone()
50 .or_else(default_reticulum_dir);
51 let config = ClientConfig::load_or_create_for_run(rngit_dir, rns_dir)?;
52 logging::init_file_logger(&config.dir.join("client_log"), config.log_level)?;
53 let remote = resolved_release_remote(&options)?;
54 let (dest_hash, repository) = parse_rns_url_with_aliases(&remote, &config.destination_aliases)?;
55
56 let default_signer_path = config.identity_path.clone();
57 let default_package_name = repository
58 .rsplit('/')
59 .next()
60 .filter(|name| !name.is_empty())
61 .unwrap_or(&repository)
62 .to_string();
63 if matches!(&options.command, ReleaseCommand::Create { local: true, .. }) {
64 let mut transport = LocalReleaseTransport;
65 run_release_command_with_defaults(
66 &mut transport,
67 &options.command,
68 Some(default_signer_path.as_path()),
69 Some(default_package_name.as_str()),
70 Some(&dest_hash),
71 Some(repository.as_str()),
72 io::stdout(),
73 )
74 } else {
75 let origin_path = repository.clone();
76 let client = SyncClient::connect(config, dest_hash)?;
77 let mut transport = NetReleaseTransport { client, repository };
78 run_release_command_with_defaults(
79 &mut transport,
80 &options.command,
81 Some(default_signer_path.as_path()),
82 Some(default_package_name.as_str()),
83 Some(&dest_hash),
84 Some(origin_path.as_str()),
85 io::stdout(),
86 )
87 }
88}
89
90trait ReleaseTransport {
91 fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>>;
92
93 fn request_with_timeout(&mut self, data: Vec<u8>, _timeout: Duration) -> Result<Vec<u8>> {
94 self.request(data)
95 }
96
97 fn request_resource_with_timeout(
98 &mut self,
99 data: Vec<u8>,
100 timeout: Duration,
101 ) -> Result<Vec<u8>> {
102 self.request_with_timeout(data, timeout)
103 }
104}
105
106struct NetReleaseTransport {
107 client: SyncClient,
108 repository: String,
109}
110
111impl ReleaseTransport for NetReleaseTransport {
112 fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
113 self.request_with_timeout(data, RELEASE_TIMEOUT_MEDIUM)
114 }
115
116 fn request_with_timeout(&mut self, data: Vec<u8>, timeout: Duration) -> Result<Vec<u8>> {
117 let response = self.client.request_with_timeout(
118 protocol::PATH_RELEASE,
119 request_with_repository(data, &self.repository)?,
120 timeout,
121 )?;
122 let bytes = protocol::response_bin(&response.data)?;
123 decode_status(bytes)
124 }
125
126 fn request_resource_with_timeout(
127 &mut self,
128 data: Vec<u8>,
129 timeout: Duration,
130 ) -> Result<Vec<u8>> {
131 let response = self.client.request_with_timeout(
132 protocol::PATH_RELEASE,
133 request_with_repository(data, &self.repository)?,
134 timeout,
135 )?;
136 if let Some(metadata) = response.metadata {
137 crate::client::ensure_metadata_ok(&metadata)?;
138 return Ok(response.data);
139 }
140 let bytes = protocol::response_bin(&response.data)?;
141 decode_status(bytes)
142 }
143}
144
145struct LocalReleaseTransport;
146
147impl ReleaseTransport for LocalReleaseTransport {
148 fn request(&mut self, _data: Vec<u8>) -> Result<Vec<u8>> {
149 Err(Error::msg(
150 "local release mode does not use network requests",
151 ))
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156struct ReleaseOptions {
157 config_dir: Option<PathBuf>,
158 rns_config_dir: Option<PathBuf>,
159 remote: String,
160 command: ReleaseCommand,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164enum ReleaseCommand {
165 List,
166 View {
167 tag: String,
168 },
169 Fetch {
170 target: String,
171 required_signer: Option<String>,
172 offline: bool,
173 },
174 Create {
175 tag: String,
176 artifacts_dir: PathBuf,
177 notes_path: Option<PathBuf>,
178 signer_path: Option<PathBuf>,
179 package_name: Option<String>,
180 local: bool,
181 },
182 Delete {
183 tag: String,
184 yes: bool,
185 },
186 Latest {
187 tag: String,
188 yes: bool,
189 },
190}
191
192impl ReleaseOptions {
193 fn parse<I>(args: I) -> Result<Self>
194 where
195 I: IntoIterator<Item = String>,
196 {
197 let mut config_dir = None;
198 let mut rns_config_dir = None;
199 let mut notes_path = None;
200 let mut signer_path = None;
201 let mut package_name = None;
202 let mut local = false;
203 let mut offline = false;
204 let mut yes = false;
205 let mut positional = Vec::new();
206 let mut args = args.into_iter();
207 while let Some(arg) = args.next() {
208 match arg.as_str() {
209 "-c" | "--config" => {
210 config_dir = Some(PathBuf::from(
211 args.next()
212 .ok_or_else(|| Error::msg("missing config path"))?,
213 ));
214 }
215 "--rnsconfig" => {
216 rns_config_dir = Some(PathBuf::from(
217 args.next()
218 .ok_or_else(|| Error::msg("missing RNS config path"))?,
219 ));
220 }
221 "--notes" => {
222 notes_path = Some(PathBuf::from(
223 args.next()
224 .ok_or_else(|| Error::msg("missing notes path"))?,
225 ));
226 }
227 "-s" | "--signer" => {
228 signer_path = Some(PathBuf::from(
229 args.next()
230 .ok_or_else(|| Error::msg("missing signer identity path"))?,
231 ));
232 }
233 "-n" | "--name" => {
234 package_name = Some(
235 args.next()
236 .ok_or_else(|| Error::msg("missing package name"))?,
237 );
238 }
239 "-L" | "--local" => local = true,
240 "-o" | "--offline" => offline = true,
241 "-y" | "--yes" => yes = true,
242 "-h" | "--help" => return Err(Error::msg(usage())),
243 other => positional.push(other.to_string()),
244 }
245 }
246
247 if positional.len() < 2 {
248 return Err(Error::msg(usage()));
249 }
250 let remote = positional[0].clone();
251 let command = match positional[1].as_str() {
252 "list" => {
253 if positional.len() != 2 {
254 return Err(Error::msg("release list does not take a target"));
255 }
256 ReleaseCommand::List
257 }
258 "view" => ReleaseCommand::View {
259 tag: positional
260 .get(2)
261 .cloned()
262 .ok_or_else(|| Error::msg("release view requires a tag"))?,
263 },
264 "fetch" => ReleaseCommand::Fetch {
265 target: positional
266 .get(2)
267 .cloned()
268 .unwrap_or_else(|| "latest:all".into()),
269 required_signer: signer_path.map(|path| path.to_string_lossy().into_owned()),
270 offline,
271 },
272 "verify" => ReleaseCommand::Fetch {
273 target: positional
274 .get(2)
275 .cloned()
276 .unwrap_or_else(|| "latest:all".into()),
277 required_signer: signer_path.map(|path| path.to_string_lossy().into_owned()),
278 offline: true,
279 },
280 "create" => parse_create_target(
281 &positional[2..],
282 notes_path,
283 signer_path,
284 package_name,
285 local,
286 )?,
287 "delete" => ReleaseCommand::Delete {
288 tag: positional
289 .get(2)
290 .cloned()
291 .ok_or_else(|| Error::msg("release delete requires a tag"))?,
292 yes,
293 },
294 "latest" => ReleaseCommand::Latest {
295 tag: positional
296 .get(2)
297 .cloned()
298 .ok_or_else(|| Error::msg("release latest requires a tag"))?,
299 yes,
300 },
301 other => return Err(Error::msg(format!("unknown release operation {other}"))),
302 };
303 Ok(Self {
304 config_dir,
305 rns_config_dir,
306 remote,
307 command,
308 })
309 }
310}
311
312fn parse_create_target(
313 args: &[String],
314 notes_path: Option<PathBuf>,
315 signer_path: Option<PathBuf>,
316 package_name: Option<String>,
317 local: bool,
318) -> Result<ReleaseCommand> {
319 let first = args
320 .first()
321 .ok_or_else(|| Error::msg("release create requires <tag>:<artifacts-dir>"))?;
322 let (tag, artifacts_dir) = if let Some((tag, dir)) = first.split_once(':') {
323 (tag.to_string(), PathBuf::from(dir))
324 } else {
325 let dir = args
326 .get(1)
327 .ok_or_else(|| Error::msg("release create requires an artifacts directory"))?;
328 (first.clone(), PathBuf::from(dir))
329 };
330 if tag.is_empty() {
331 return Err(Error::msg("release tag cannot be empty"));
332 }
333 Ok(ReleaseCommand::Create {
334 tag,
335 artifacts_dir,
336 notes_path,
337 signer_path,
338 package_name,
339 local,
340 })
341}
342
343#[cfg(test)]
344fn run_release_command(
345 transport: &mut impl ReleaseTransport,
346 command: &ReleaseCommand,
347 output: impl Write,
348) -> Result<()> {
349 run_release_command_with_defaults(transport, command, None, None, None, None, output)
350}
351
352fn run_release_command_with_defaults(
353 transport: &mut impl ReleaseTransport,
354 command: &ReleaseCommand,
355 default_signer_path: Option<&Path>,
356 default_package_name: Option<&str>,
357 origin_hash: Option<&[u8; 16]>,
358 origin_path: Option<&str>,
359 mut output: impl Write,
360) -> Result<()> {
361 match command {
362 ReleaseCommand::List => {
363 let body =
364 transport.request_with_timeout(request("list", &[]), RELEASE_TIMEOUT_SHORT)?;
365 let releases = msgpack::unpack_exact(&body)
366 .map_err(|e| Error::msg(format!("invalid release list: {e}")))?;
367 print_release_list(&releases, &mut output)
368 }
369 ReleaseCommand::View { tag } => {
370 let body = transport.request_with_timeout(
371 request("view", &[("tag", Value::Str(tag.clone()))]),
372 RELEASE_TIMEOUT_MEDIUM,
373 )?;
374 let release = msgpack::unpack_exact(&body)
375 .map_err(|e| Error::msg(format!("invalid release view: {e}")))?;
376 print_release_view(&release, &mut output)
377 }
378 ReleaseCommand::Fetch {
379 target,
380 required_signer,
381 offline: _,
382 } => fetch_release_into(
383 transport,
384 target,
385 required_signer.as_deref(),
386 Path::new("."),
387 &mut output,
388 ),
389 ReleaseCommand::Create {
390 tag,
391 artifacts_dir,
392 notes_path,
393 signer_path,
394 package_name,
395 local,
396 } => create_release(
397 transport,
398 CreateReleaseOptions {
399 tag,
400 artifacts_dir,
401 notes_path: notes_path.as_deref(),
402 signer_path: signer_path.as_deref().or(default_signer_path),
403 package_name: package_name.as_deref().or(default_package_name),
404 origin_hash,
405 origin_path,
406 local: *local,
407 },
408 &mut output,
409 ),
410 ReleaseCommand::Delete { tag, yes } => {
411 if !yes {
412 return Err(Error::msg("release delete requires --yes"));
413 }
414 transport.request_with_timeout(
415 request("delete", &[("tag", Value::Str(tag.clone()))]),
416 RELEASE_TIMEOUT_SHORT,
417 )?;
418 writeln!(output, "Deleted release {tag}")?;
419 Ok(())
420 }
421 ReleaseCommand::Latest { tag, yes } => {
422 if !yes {
423 return Err(Error::msg("release latest requires --yes"));
424 }
425 transport.request_with_timeout(
426 request("latest", &[("tag", Value::Str(tag.clone()))]),
427 RELEASE_TIMEOUT_SHORT,
428 )?;
429 writeln!(output, "Release {tag} set as latest")?;
430 Ok(())
431 }
432 }
433}
434
435struct CreateReleaseOptions<'a> {
436 tag: &'a str,
437 artifacts_dir: &'a Path,
438 notes_path: Option<&'a Path>,
439 signer_path: Option<&'a Path>,
440 package_name: Option<&'a str>,
441 origin_hash: Option<&'a [u8; 16]>,
442 origin_path: Option<&'a str>,
443 local: bool,
444}
445
446fn create_release(
447 transport: &mut impl ReleaseTransport,
448 options: CreateReleaseOptions<'_>,
449 mut output: impl Write,
450) -> Result<()> {
451 let CreateReleaseOptions {
452 tag,
453 artifacts_dir,
454 notes_path,
455 signer_path,
456 package_name,
457 origin_hash,
458 origin_path,
459 local,
460 } = options;
461 if !artifacts_dir.is_dir() {
462 return Err(Error::msg(format!(
463 "artifact directory does not exist: {}",
464 artifacts_dir.display()
465 )));
466 }
467 let notes = load_notes(artifacts_dir, notes_path)?;
468 let mut artifacts = artifact_files(artifacts_dir)?;
469 if local && signer_path.is_none() {
470 return Err(Error::msg(
471 "local release creation requires a signer identity",
472 ));
473 }
474 if let Some(signer_path) = signer_path {
475 sign_release_artifacts(SignReleaseOptions {
476 artifacts_dir,
477 artifacts: &artifacts,
478 tag,
479 notes: ¬es.content,
480 signer_path,
481 package_name,
482 origin_hash,
483 origin_path,
484 })?;
485 artifacts = artifact_files(artifacts_dir)?;
486 }
487 if local {
488 let package_name = release_package_name(artifacts_dir, package_name);
489 writeln!(
490 output,
491 "Local release {package_name}:{tag} generated successfully in {}",
492 artifacts_dir.display()
493 )?;
494 return Ok(());
495 }
496 writeln!(output, "Initializing release {tag}")?;
497 transport.request_with_timeout(
498 request(
499 "create",
500 &[
501 ("step", Value::Str("init".into())),
502 ("tag", Value::Str(tag.to_string())),
503 ("notes", Value::Str(notes.content)),
504 ("notes_format", Value::Str(notes.format)),
505 ],
506 ),
507 RELEASE_TIMEOUT_SHORT,
508 )?;
509
510 for (index, artifact) in artifacts.iter().enumerate() {
511 let data = fs::read(&artifact.path)?;
512 writeln!(
513 output,
514 "Uploading {} ({}/{}, {} bytes)",
515 artifact.name,
516 index + 1,
517 artifacts.len(),
518 data.len()
519 )?;
520 transport.request_with_timeout(
521 request(
522 "create",
523 &[
524 ("step", Value::Str("artifact".into())),
525 ("tag", Value::Str(tag.to_string())),
526 ("artifact_name", Value::Str(artifact.name.clone())),
527 ("artifact_data", Value::Bin(data)),
528 ],
529 ),
530 RELEASE_TIMEOUT_LONG,
531 )?;
532 }
533 writeln!(output, "Finalizing release {tag}")?;
534 transport.request_with_timeout(
535 request(
536 "create",
537 &[
538 ("step", Value::Str("finalize".into())),
539 ("tag", Value::Str(tag.to_string())),
540 ],
541 ),
542 RELEASE_TIMEOUT_MEDIUM,
543 )?;
544 writeln!(
545 output,
546 "Created release {tag} with {} artifact(s)",
547 artifacts.len()
548 )?;
549 Ok(())
550}
551
552fn release_package_name(artifacts_dir: &Path, package_name: Option<&str>) -> String {
553 package_name
554 .map(str::to_string)
555 .or_else(|| {
556 artifacts_dir
557 .file_name()
558 .map(|name| name.to_string_lossy().into_owned())
559 })
560 .unwrap_or_else(|| "release".into())
561}
562
563struct SignReleaseOptions<'a> {
564 artifacts_dir: &'a Path,
565 artifacts: &'a [ArtifactFile],
566 tag: &'a str,
567 notes: &'a str,
568 signer_path: &'a Path,
569 package_name: Option<&'a str>,
570 origin_hash: Option<&'a [u8; 16]>,
571 origin_path: Option<&'a str>,
572}
573
574fn sign_release_artifacts(options: SignReleaseOptions<'_>) -> Result<()> {
575 let SignReleaseOptions {
576 artifacts_dir,
577 artifacts,
578 tag,
579 notes,
580 signer_path,
581 package_name,
582 origin_hash,
583 origin_path,
584 } = options;
585 sign_release_artifacts_in_repo(
586 artifacts_dir,
587 artifacts,
588 tag,
589 notes,
590 signer_path,
591 package_name,
592 origin_hash,
593 origin_path,
594 Path::new("."),
595 )
596}
597
598#[allow(clippy::too_many_arguments)]
599fn sign_release_artifacts_in_repo(
600 artifacts_dir: &Path,
601 artifacts: &[ArtifactFile],
602 tag: &str,
603 notes: &str,
604 signer_path: &Path,
605 package_name: Option<&str>,
606 origin_hash: Option<&[u8; 16]>,
607 origin_path: Option<&str>,
608 repo_path: &Path,
609) -> Result<()> {
610 let signer = rns_net::storage::load_identity(signer_path).map_err(|e| {
611 Error::msg(format!(
612 "could not load signer identity {}: {e}",
613 signer_path.display()
614 ))
615 })?;
616 let timestamp = current_unix_timestamp()?;
617 let commit_hash = resolve_tag_commit_hash(repo_path, tag)?;
618 let mut manifest_artifacts = Vec::new();
619
620 for artifact in artifacts {
621 if artifact.name.ends_with(".rsg") || artifact.name.ends_with(".rsm") {
622 continue;
623 }
624 let data = fs::read(&artifact.path)?;
625 let rsg = create_rsg(
626 &signer,
627 &data,
628 false,
629 vec![("timestamp".into(), Value::UInt(timestamp))],
630 )?;
631 fs::write(artifacts_dir.join(format!("{}.rsg", artifact.name)), &rsg)?;
632 manifest_artifacts.push(Value::Map(vec![
633 (Value::Str("name".into()), Value::Str(artifact.name.clone())),
634 (Value::Str("rsg".into()), Value::Bin(rsg)),
635 ]));
636 }
637
638 let package_name = release_package_name(artifacts_dir, package_name);
639 let mut manifest_meta = vec![
640 ("name".into(), Value::Str(package_name)),
641 ("version".into(), Value::Str(tag.to_string())),
642 ("released".into(), Value::Str(unix_timestamp_iso(timestamp))),
643 ("timestamp".into(), Value::UInt(timestamp)),
644 (
645 "commit".into(),
646 commit_hash.map(Value::Str).unwrap_or(Value::Nil),
647 ),
648 ("artifacts".into(), Value::Array(manifest_artifacts)),
649 ];
650 if let Some(origin_hash) = origin_hash {
651 manifest_meta.push(("origin".into(), Value::Bin(origin_hash.to_vec())));
652 }
653 if let Some(origin_path) = origin_path {
654 manifest_meta.push(("path".into(), Value::Str(origin_path.to_string())));
655 }
656 let manifest = create_rsg(&signer, notes.as_bytes(), true, manifest_meta)?;
657 fs::write(artifacts_dir.join("manifest.rsm"), manifest)?;
658 Ok(())
659}
660
661fn resolve_tag_commit_hash(repo_path: &Path, tag: &str) -> Result<Option<String>> {
662 let output = Command::new("git")
663 .arg("rev-list")
664 .arg("-n")
665 .arg("1")
666 .arg(tag)
667 .current_dir(repo_path)
668 .output()
669 .map_err(|e| Error::msg(format!("could not run git to resolve tag {tag}: {e}")))?;
670 if !output.status.success() {
671 return Ok(None);
672 }
673 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
674 Ok((!commit.is_empty()).then_some(commit))
675}
676
677fn fetch_release_into(
678 transport: &mut impl ReleaseTransport,
679 target: &str,
680 required_signer: Option<&str>,
681 output_dir: &Path,
682 mut output: impl Write,
683) -> Result<()> {
684 let (tag, requested_artifact) = parse_fetch_target(target)?;
685 let required_signer = required_signer.map(parse_hex_16).transpose()?;
686 let manifest_bytes = transport.request_resource_with_timeout(
687 request(
688 "fetch",
689 &[
690 ("tag", Value::Str(tag.clone())),
691 ("artifact", Value::Str("manifest.rsm".into())),
692 ],
693 ),
694 RELEASE_TIMEOUT_LONG,
695 )?;
696 let manifest = validate_embedded_manifest(&manifest_bytes, required_signer)?;
697 validate_release_rsm_structure(&manifest.envelope)?;
698 writeln!(
699 output,
700 "Release manifest validated, signed by {}",
701 crate::util::hex(&manifest.signer_hash)
702 )?;
703 let manifest_name = manifest_output_name(&manifest.envelope)?;
704 fs::write(output_dir.join(manifest_name), &manifest_bytes)?;
705 let artifacts = manifest_artifacts(&manifest.envelope)?;
706 if artifacts.is_empty() {
707 return Err(Error::msg("Release manifest contains no artifacts"));
708 }
709 let selected = select_manifest_artifacts(&artifacts, &requested_artifact)?;
710 fs::create_dir_all(output_dir)?;
711
712 for artifact in selected {
713 let output_path = output_dir.join(&artifact.name);
714 if output_path.exists() {
715 let existing = fs::read(&output_path)?;
716 if validate_rsg(&artifact.rsg, &existing, required_signer).is_ok() {
717 writeln!(
718 output,
719 "Existing file {} validated, not fetching again",
720 artifact.name
721 )?;
722 continue;
723 }
724 writeln!(
725 output,
726 "Existing file {} does not match manifest, fetching and overwriting",
727 artifact.name
728 )?;
729 }
730 let data = transport.request_resource_with_timeout(
731 request(
732 "fetch",
733 &[
734 ("tag", Value::Str(tag.clone())),
735 ("artifact", Value::Str(artifact.name.clone())),
736 ],
737 ),
738 RELEASE_TIMEOUT_LONG,
739 )?;
740 validate_rsg(&artifact.rsg, &data, required_signer)?;
741 fs::write(&output_path, data)?;
742 writeln!(output, "Fetched {}", artifact.name)?;
743 }
744 Ok(())
745}
746
747fn verify_release_manifest_offline(
748 manifest_path: &Path,
749 target: &str,
750 required_signer: Option<&str>,
751 mut output: impl Write,
752) -> Result<()> {
753 let (_tag, requested_artifact) = parse_fetch_target(target)?;
754 let required_signer = required_signer.map(parse_hex_16).transpose()?;
755 let manifest_bytes = fs::read(manifest_path)?;
756 let manifest = validate_embedded_manifest(&manifest_bytes, required_signer)?;
757 validate_release_rsm_structure(&manifest.envelope)?;
758 writeln!(
759 output,
760 "Release manifest validated, signed by {}",
761 crate::util::hex(&manifest.signer_hash)
762 )?;
763 let artifacts = manifest_artifacts(&manifest.envelope)?;
764 if artifacts.is_empty() {
765 return Err(Error::msg("Release manifest contains no artifacts"));
766 }
767 let selected = select_manifest_artifacts(&artifacts, &requested_artifact)?;
768 let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
769 let mut valid_count = 0usize;
770 let plural = if selected.len() == 1 { "" } else { "s" };
771 writeln!(output, "Validating {} artifact{plural}...", selected.len())?;
772 for artifact in &selected {
773 let path = base_dir.join(&artifact.name);
774 if !path.exists() {
775 writeln!(
776 output,
777 " File {} from manifest does not exist locally, cannot validate",
778 artifact.name
779 )?;
780 continue;
781 }
782 let data = fs::read(&path)?;
783 if validate_rsg(&artifact.rsg, &data, required_signer).is_ok() {
784 writeln!(
785 output,
786 " File {} validated against manifest {}",
787 artifact.name,
788 manifest_path.display()
789 )?;
790 valid_count += 1;
791 } else {
792 writeln!(output, " File {} does not match manifest", artifact.name)?;
793 }
794 }
795 if valid_count == selected.len() {
796 writeln!(
797 output,
798 "
799All files validated"
800 )?;
801 Ok(())
802 } else {
803 writeln!(
804 output,
805 "
806Release is not valid"
807 )?;
808 Err(Error::msg("Release is not valid"))
809 }
810}
811
812fn resolved_release_remote(options: &ReleaseOptions) -> Result<String> {
813 if let ReleaseCommand::Fetch {
814 required_signer, ..
815 } = &options.command
816 {
817 if let Some(path) = local_manifest_path(&options.remote) {
818 return release_remote_from_manifest_file(&path, required_signer.as_deref());
819 }
820 }
821 Ok(options.remote.clone())
822}
823
824fn local_manifest_path(remote: &str) -> Option<PathBuf> {
825 let path = PathBuf::from(remote);
826 (path.extension().and_then(|ext| ext.to_str()) == Some("rsm") && path.is_file()).then_some(path)
827}
828
829fn release_remote_from_manifest_file(path: &Path, required_signer: Option<&str>) -> Result<String> {
830 let required_signer = required_signer.map(parse_hex_16).transpose()?;
831 let manifest_bytes = fs::read(path)?;
832 let manifest = validate_embedded_manifest(&manifest_bytes, required_signer)?;
833 let info = validate_release_rsm_structure(&manifest.envelope)?;
834 Ok(format!(
835 "rns://{}/{}",
836 crate::util::hex(&info.origin),
837 info.path
838 ))
839}
840
841fn parse_fetch_target(target: &str) -> Result<(String, String)> {
842 let (tag, artifact) = target
843 .split_once(':')
844 .ok_or_else(|| Error::msg("Invalid release specification"))?;
845 if tag.is_empty() || artifact.is_empty() {
846 return Err(Error::msg("Invalid release specification"));
847 }
848 Ok((tag.to_string(), artifact.to_string()))
849}
850
851#[derive(Debug)]
852struct ValidRsg {
853 envelope: Value,
854 signer_hash: [u8; 16],
855}
856
857#[derive(Debug, Clone)]
858struct ManifestArtifact {
859 name: String,
860 rsg: Vec<u8>,
861}
862
863fn validate_embedded_manifest(rsg: &[u8], required_signer: Option<[u8; 16]>) -> Result<ValidRsg> {
864 let envelope = rsg_envelope(rsg)?;
865 let message = rsg_embedded_message(&envelope)
866 .ok_or_else(|| Error::msg("No embedded message in release manifest"))?;
867 validate_rsg_parts(rsg, &message, required_signer, Some(envelope))
868}
869
870fn validate_rsg(rsg: &[u8], message: &[u8], required_signer: Option<[u8; 16]>) -> Result<ValidRsg> {
871 validate_rsg_parts(rsg, message, required_signer, None)
872}
873
874fn validate_rsg_parts(
875 rsg: &[u8],
876 message: &[u8],
877 required_signer: Option<[u8; 16]>,
878 envelope: Option<Value>,
879) -> Result<ValidRsg> {
880 const SIG_LEN: usize = 64;
881 if rsg.len() <= SIG_LEN {
882 return Err(Error::msg("invalid RSG"));
883 }
884 let signature: [u8; SIG_LEN] = rsg[..SIG_LEN].try_into().unwrap();
885 let envelope_bytes = &rsg[SIG_LEN..];
886 let envelope = match envelope {
887 Some(envelope) => envelope,
888 None => msgpack::unpack_exact(envelope_bytes)
889 .map_err(|e| Error::msg(format!("invalid RSG envelope: {e}")))?,
890 };
891 if envelope.map_get("hashtype").and_then(Value::as_str) != Some("sha256") {
892 return Err(Error::msg("unsupported RSG hash type"));
893 }
894 let signed_hash = envelope
895 .map_get("hash")
896 .and_then(Value::as_bin)
897 .ok_or_else(|| Error::msg("RSG is missing hash"))?;
898 let calculated_hash = sha256(message);
899 if signed_hash != calculated_hash.as_slice() {
900 return Err(Error::msg("RSG hash does not match message"));
901 }
902 let meta = envelope
903 .map_get("meta")
904 .ok_or_else(|| Error::msg("RSG is missing metadata"))?;
905 let pubkey = meta
906 .map_get("pubkey")
907 .and_then(Value::as_bin)
908 .ok_or_else(|| Error::msg("RSG is missing public key"))?;
909 let public_key: [u8; 64] = pubkey
910 .try_into()
911 .map_err(|_| Error::msg("RSG public key has invalid length"))?;
912 let identity = Identity::from_public_key(&public_key);
913 let signer_hash = *identity.hash();
914 let meta_signer = meta
915 .map_get("signer")
916 .and_then(Value::as_bin)
917 .ok_or_else(|| Error::msg("RSG is missing signer"))?;
918 if meta_signer != signer_hash.as_slice() {
919 return Err(Error::msg("RSG signer does not match public key"));
920 }
921 if let Some(required) = required_signer {
922 if signer_hash != required {
923 return Err(Error::msg(format!(
924 "RSG is not signed by required signer {}",
925 crate::util::hex(&required)
926 )));
927 }
928 }
929 if !identity.verify(&signature, envelope_bytes) {
930 return Err(Error::msg("RSG signature verification failed"));
931 }
932 Ok(ValidRsg {
933 envelope,
934 signer_hash,
935 })
936}
937
938fn rsg_envelope(rsg: &[u8]) -> Result<Value> {
939 const SIG_LEN: usize = 64;
940 if rsg.len() <= SIG_LEN {
941 return Err(Error::msg("invalid RSG"));
942 }
943 msgpack::unpack_exact(&rsg[SIG_LEN..])
944 .map_err(|e| Error::msg(format!("invalid RSG envelope: {e}")))
945}
946
947fn rsg_embedded_message(value: &Value) -> Option<Vec<u8>> {
948 value
949 .map_get("message")
950 .and_then(Value::as_bin)
951 .map(ToOwned::to_owned)
952 .or_else(|| {
953 value
954 .map_get("message")
955 .and_then(Value::as_str)
956 .map(|message| message.as_bytes().to_vec())
957 })
958}
959
960#[derive(Debug, Clone, PartialEq, Eq)]
961struct ReleaseManifestInfo {
962 name: String,
963 version: String,
964 origin: [u8; 16],
965 path: String,
966}
967
968fn validate_release_rsm_structure(manifest: &Value) -> Result<ReleaseManifestInfo> {
969 let meta = manifest
970 .map_get("meta")
971 .ok_or_else(|| Error::msg("No release metadata in manifest"))?;
972 let name = meta
973 .map_get("name")
974 .and_then(Value::as_str)
975 .and_then(sanitize_manifest_component)
976 .ok_or_else(|| Error::msg("Incomplete package data in manifest"))?;
977 let version = meta
978 .map_get("version")
979 .and_then(Value::as_str)
980 .and_then(sanitize_manifest_component)
981 .ok_or_else(|| Error::msg("Incomplete package data in manifest"))?;
982 let origin = meta
983 .map_get("origin")
984 .ok_or_else(|| Error::msg("Incomplete release origin data in manifest"))?;
985 let origin = origin
986 .as_bin()
987 .ok_or_else(|| Error::msg("Invalid origin hash in manifest"))?;
988 let origin: [u8; 16] = origin
989 .try_into()
990 .map_err(|_| Error::msg("Invalid origin hash length in manifest"))?;
991 let path = meta
992 .map_get("path")
993 .and_then(Value::as_str)
994 .filter(|path| !path.is_empty())
995 .ok_or_else(|| Error::msg("Incomplete release origin data in manifest"))?;
996 Ok(ReleaseManifestInfo {
997 name,
998 version,
999 origin,
1000 path: path.to_string(),
1001 })
1002}
1003
1004fn manifest_output_name(manifest: &Value) -> Result<String> {
1005 let info = validate_release_rsm_structure(manifest)?;
1006 Ok(format!("{}_{}.rsm", info.name, info.version))
1007}
1008
1009fn sanitize_manifest_component(value: &str) -> Option<String> {
1010 (!value.is_empty()
1011 && value != "."
1012 && value != ".."
1013 && !value.contains('/')
1014 && !value.contains('\\'))
1015 .then(|| value.to_string())
1016}
1017
1018fn manifest_artifacts(manifest: &Value) -> Result<Vec<ManifestArtifact>> {
1019 let artifacts = manifest
1020 .map_get("meta")
1021 .and_then(|meta| meta.map_get("artifacts"))
1022 .and_then(Value::as_array)
1023 .ok_or_else(|| Error::msg("Release manifest contains no artifacts"))?;
1024 artifacts
1025 .iter()
1026 .map(|artifact| {
1027 let name = artifact
1028 .map_get("name")
1029 .and_then(Value::as_str)
1030 .ok_or_else(|| Error::msg("manifest artifact is missing name"))?;
1031 if name.contains('/') || name.contains('\\') || name == "." || name == ".." {
1032 return Err(Error::msg("manifest artifact has invalid name"));
1033 }
1034 let rsg = artifact
1035 .map_get("rsg")
1036 .and_then(Value::as_bin)
1037 .ok_or_else(|| Error::msg("manifest artifact is missing RSG"))?;
1038 Ok(ManifestArtifact {
1039 name: name.to_string(),
1040 rsg: rsg.to_vec(),
1041 })
1042 })
1043 .collect()
1044}
1045
1046fn select_manifest_artifacts(
1047 artifacts: &[ManifestArtifact],
1048 requested: &str,
1049) -> Result<Vec<ManifestArtifact>> {
1050 if requested == "all" {
1051 return Ok(artifacts.to_vec());
1052 }
1053 let selected: Vec<_> = artifacts
1054 .iter()
1055 .filter(|artifact| artifact_pattern_matches(requested, &artifact.name))
1056 .cloned()
1057 .collect();
1058 if selected.is_empty() {
1059 return Err(Error::msg("No available artifacts specified for fetch"));
1060 }
1061 Ok(selected)
1062}
1063
1064fn artifact_pattern_matches(pattern: &str, name: &str) -> bool {
1065 let pattern: Vec<char> = pattern.chars().collect();
1066 let name: Vec<char> = name.chars().collect();
1067 wildcard_match(&pattern, 0, &name, 0)
1068}
1069
1070fn wildcard_match(pattern: &[char], pi: usize, name: &[char], ni: usize) -> bool {
1071 if pi == pattern.len() {
1072 return ni == name.len();
1073 }
1074 match pattern[pi] {
1075 '*' => {
1076 let mut next = pi + 1;
1077 while next < pattern.len() && pattern[next] == '*' {
1078 next += 1;
1079 }
1080 (ni..=name.len()).any(|candidate| wildcard_match(pattern, next, name, candidate))
1081 }
1082 '?' => ni < name.len() && wildcard_match(pattern, pi + 1, name, ni + 1),
1083 '[' => {
1084 let Some((matches_class, next)) = match_char_class(pattern, pi, name.get(ni).copied())
1085 else {
1086 return ni < name.len()
1087 && name[ni] == '['
1088 && wildcard_match(pattern, pi + 1, name, ni + 1);
1089 };
1090 matches_class && wildcard_match(pattern, next, name, ni + 1)
1091 }
1092 literal => {
1093 ni < name.len() && name[ni] == literal && wildcard_match(pattern, pi + 1, name, ni + 1)
1094 }
1095 }
1096}
1097
1098fn match_char_class(pattern: &[char], start: usize, value: Option<char>) -> Option<(bool, usize)> {
1099 let value = value?;
1100 let mut end = start + 1;
1101 while end < pattern.len() && pattern[end] != ']' {
1102 end += 1;
1103 }
1104 if end == pattern.len() || end == start + 1 {
1105 return None;
1106 }
1107
1108 let mut i = start + 1;
1109 let negated = matches!(pattern.get(i), Some('!' | '^'));
1110 if negated {
1111 i += 1;
1112 }
1113 if i >= end {
1114 return None;
1115 }
1116
1117 let mut matched = false;
1118 while i < end {
1119 if i + 2 < end && pattern[i + 1] == '-' {
1120 let lo = pattern[i];
1121 let hi = pattern[i + 2];
1122 if lo <= value && value <= hi {
1123 matched = true;
1124 }
1125 i += 3;
1126 } else {
1127 if pattern[i] == value {
1128 matched = true;
1129 }
1130 i += 1;
1131 }
1132 }
1133
1134 Some((if negated { !matched } else { matched }, end + 1))
1135}
1136
1137fn create_rsg(
1138 identity: &Identity,
1139 message: &[u8],
1140 embed_message: bool,
1141 extra_meta: Vec<(String, Value)>,
1142) -> Result<Vec<u8>> {
1143 const SIG_LEN: usize = 64;
1144 let public_key = identity
1145 .get_public_key()
1146 .ok_or_else(|| Error::msg("signer identity has no public key"))?;
1147 let mut meta = vec![
1148 (
1149 Value::Str("signer".into()),
1150 Value::Bin(identity.hash().to_vec()),
1151 ),
1152 (Value::Str("pubkey".into()), Value::Bin(public_key.to_vec())),
1153 ];
1154 for (key, value) in extra_meta {
1155 if !meta
1156 .iter()
1157 .any(|(existing, _)| matches!(existing, Value::Str(existing) if existing == &key))
1158 {
1159 meta.push((Value::Str(key), value));
1160 }
1161 }
1162
1163 let mut envelope = vec![
1164 (Value::Str("hashtype".into()), Value::Str("sha256".into())),
1165 (
1166 Value::Str("hash".into()),
1167 Value::Bin(sha256(message).to_vec()),
1168 ),
1169 (Value::Str("meta".into()), Value::Map(meta)),
1170 ];
1171 if embed_message {
1172 envelope.push((Value::Str("message".into()), Value::Bin(message.to_vec())));
1173 }
1174 let envelope = msgpack::pack(&Value::Map(envelope));
1175 let signature = identity
1176 .sign(&envelope)
1177 .map_err(|_| Error::msg("signer identity has no private key"))?;
1178 let mut rsg = Vec::with_capacity(SIG_LEN + envelope.len());
1179 rsg.extend_from_slice(&signature);
1180 rsg.extend_from_slice(&envelope);
1181 Ok(rsg)
1182}
1183
1184fn current_unix_timestamp() -> Result<u64> {
1185 Ok(SystemTime::now()
1186 .duration_since(UNIX_EPOCH)
1187 .map_err(|e| Error::msg(format!("system clock is before UNIX epoch: {e}")))?
1188 .as_secs())
1189}
1190
1191fn unix_timestamp_iso(timestamp: u64) -> String {
1192 let days = (timestamp / 86_400) as i64;
1193 let seconds = timestamp % 86_400;
1194 let (year, month, day) = civil_from_days(days);
1195 let hour = seconds / 3_600;
1196 let minute = (seconds % 3_600) / 60;
1197 let second = seconds % 60;
1198 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
1199}
1200
1201fn civil_from_days(days_since_epoch: i64) -> (i64, u64, u64) {
1202 let z = days_since_epoch + 719_468;
1203 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1204 let doe = z - era * 146_097;
1205 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
1206 let y = yoe + era * 400;
1207 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1208 let mp = (5 * doy + 2) / 153;
1209 let day = doy - (153 * mp + 2) / 5 + 1;
1210 let month = mp + if mp < 10 { 3 } else { -9 };
1211 let year = y + if month <= 2 { 1 } else { 0 };
1212 (year, month as u64, day as u64)
1213}
1214
1215fn request(operation: &str, fields: &[(&str, Value)]) -> Vec<u8> {
1216 let mut map = vec![
1217 (
1218 Value::UInt(protocol::IDX_REPOSITORY),
1219 Value::Str("__repository__".into()),
1220 ),
1221 (
1222 Value::Str("operation".into()),
1223 Value::Str(operation.to_string()),
1224 ),
1225 ];
1226 map.extend(
1227 fields
1228 .iter()
1229 .map(|(key, value)| (Value::Str((*key).into()), value.clone())),
1230 );
1231 msgpack::pack(&Value::Map(map))
1232}
1233
1234fn request_with_repository(data: Vec<u8>, repository: &str) -> Result<Vec<u8>> {
1235 let mut value =
1236 msgpack::unpack_exact(&data).map_err(|e| Error::msg(format!("invalid request: {e}")))?;
1237 if let Value::Map(entries) = &mut value {
1238 for (key, val) in entries {
1239 if matches!(key, Value::UInt(v) if *v == protocol::IDX_REPOSITORY) {
1240 *val = Value::Str(repository.to_string());
1241 return Ok(msgpack::pack(&value));
1242 }
1243 }
1244 }
1245 Err(Error::msg("release request missing repository field"))
1246}
1247
1248fn usage() -> &'static str {
1249 "usage: rngit release [--config DIR] [--rnsconfig DIR] [--notes PATH] [-s|--signer PATH] [-n|--name NAME] [-L|--local] [-o|--offline] [-y|--yes] <rns://destination/repo|manifest.rsm> <list|view|fetch|verify|create|delete|latest> [target]"
1250}
1251
1252struct Notes {
1253 content: String,
1254 format: String,
1255}
1256
1257fn load_notes(artifacts_dir: &Path, explicit: Option<&Path>) -> Result<Notes> {
1258 let path = explicit
1259 .map(Path::to_path_buf)
1260 .or_else(|| {
1261 let micron = artifacts_dir.join("RELEASE.mu");
1262 micron.exists().then_some(micron)
1263 })
1264 .or_else(|| {
1265 let markdown = artifacts_dir.join("RELEASE.md");
1266 markdown.exists().then_some(markdown)
1267 });
1268 let Some(path) = path else {
1269 return Ok(Notes {
1270 content: String::new(),
1271 format: "markdown".into(),
1272 });
1273 };
1274 let format = if path.extension().and_then(|ext| ext.to_str()) == Some("mu") {
1275 "micron"
1276 } else {
1277 "markdown"
1278 };
1279 Ok(Notes {
1280 content: fs::read_to_string(path)?,
1281 format: format.into(),
1282 })
1283}
1284
1285struct ArtifactFile {
1286 name: String,
1287 path: PathBuf,
1288}
1289
1290fn artifact_files(dir: &Path) -> Result<Vec<ArtifactFile>> {
1291 let mut out = Vec::new();
1292 for entry in fs::read_dir(dir)? {
1293 let entry = entry?;
1294 if !entry.file_type()?.is_file() {
1295 continue;
1296 }
1297 let name = entry.file_name().to_string_lossy().into_owned();
1298 if matches!(name.as_str(), "RELEASE.md" | "RELEASE.mu") {
1299 continue;
1300 }
1301 out.push(ArtifactFile {
1302 name,
1303 path: entry.path(),
1304 });
1305 }
1306 out.sort_by(|a, b| a.name.cmp(&b.name));
1307 Ok(out)
1308}
1309
1310fn print_release_list(value: &Value, mut output: impl Write) -> Result<()> {
1311 let (releases, latest) = match value {
1312 Value::Array(releases) => (releases.as_slice(), None),
1313 Value::Map(_) => {
1314 let releases = value
1315 .map_get("releases")
1316 .and_then(Value::as_array)
1317 .ok_or_else(|| Error::msg("release list response is missing releases"))?;
1318 let latest = value.map_get("latest").and_then(Value::as_str);
1319 (releases, latest)
1320 }
1321 _ => return Err(Error::msg("release list response is not an array or map")),
1322 };
1323 if releases.is_empty() {
1324 writeln!(output, "No releases")?;
1325 return Ok(());
1326 }
1327 for release in releases {
1328 let tag = release.map_get("tag").and_then(Value::as_str).unwrap_or("");
1329 let status = release
1330 .map_get("status")
1331 .and_then(Value::as_str)
1332 .unwrap_or("unknown");
1333 let artifacts = release
1334 .map_get("artifacts")
1335 .and_then(Value::as_integer)
1336 .unwrap_or(0);
1337 let preview = release
1338 .map_get("preview")
1339 .and_then(Value::as_str)
1340 .unwrap_or("");
1341 let preview = preview.lines().next().unwrap_or("");
1342 writeln!(
1343 output,
1344 "{tag:<16} {status:<10} {artifacts:>3} artifact(s) {preview}"
1345 )?;
1346 }
1347 if let Some(latest) = latest.filter(|latest| !latest.is_empty()) {
1348 writeln!(output, "\nThe latest release is: {latest}")?;
1349 }
1350 Ok(())
1351}
1352
1353fn print_release_view(value: &Value, mut output: impl Write) -> Result<()> {
1354 let tag = value.map_get("tag").and_then(Value::as_str).unwrap_or("");
1355 let status = value
1356 .map_get("status")
1357 .and_then(Value::as_str)
1358 .unwrap_or("unknown");
1359 let thanks = value
1360 .map_get("thanks")
1361 .and_then(Value::as_integer)
1362 .unwrap_or(0);
1363 writeln!(output, "Release : {tag}")?;
1364 writeln!(output, "Status : {status}")?;
1365 writeln!(output, "Thanks : {thanks}")?;
1366 if let Some(notes) = value.map_get("notes").and_then(Value::as_str) {
1367 if !notes.is_empty() {
1368 writeln!(output, "\n{notes}")?;
1369 }
1370 }
1371 if let Some(artifacts) = value.map_get("artifacts").and_then(Value::as_array) {
1372 writeln!(output, "\nArtifacts:")?;
1373 for artifact in artifacts {
1374 let name = artifact
1375 .map_get("name")
1376 .and_then(Value::as_str)
1377 .unwrap_or("");
1378 let size = artifact
1379 .map_get("size")
1380 .and_then(Value::as_integer)
1381 .unwrap_or(0);
1382 writeln!(output, "- {name} ({size} bytes)")?;
1383 }
1384 }
1385 Ok(())
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391
1392 #[derive(Default)]
1393 struct FakeTransport {
1394 responses: Vec<Vec<u8>>,
1395 resource_responses: Vec<Vec<u8>>,
1396 requests: Vec<Value>,
1397 timeouts: Vec<Duration>,
1398 resource_timeouts: Vec<Duration>,
1399 }
1400
1401 impl ReleaseTransport for FakeTransport {
1402 fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
1403 self.request_with_timeout(data, RELEASE_TIMEOUT_MEDIUM)
1404 }
1405
1406 fn request_with_timeout(&mut self, data: Vec<u8>, timeout: Duration) -> Result<Vec<u8>> {
1407 self.requests.push(msgpack::unpack_exact(&data).unwrap());
1408 self.timeouts.push(timeout);
1409 Ok(self.responses.remove(0))
1410 }
1411
1412 fn request_resource_with_timeout(
1413 &mut self,
1414 data: Vec<u8>,
1415 timeout: Duration,
1416 ) -> Result<Vec<u8>> {
1417 self.requests.push(msgpack::unpack_exact(&data).unwrap());
1418 self.resource_timeouts.push(timeout);
1419 Ok(self.resource_responses.remove(0))
1420 }
1421 }
1422
1423 #[test]
1424 fn parses_release_commands() {
1425 let list = ReleaseOptions::parse([
1426 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1427 "list".into(),
1428 ])
1429 .unwrap();
1430 assert!(matches!(list.command, ReleaseCommand::List));
1431
1432 let create = ReleaseOptions::parse([
1433 "--notes".into(),
1434 "NOTES.md".into(),
1435 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1436 "create".into(),
1437 "v1:dist".into(),
1438 ])
1439 .unwrap();
1440 assert_eq!(
1441 create.command,
1442 ReleaseCommand::Create {
1443 tag: "v1".into(),
1444 artifacts_dir: PathBuf::from("dist"),
1445 notes_path: Some(PathBuf::from("NOTES.md")),
1446 signer_path: None,
1447 package_name: None,
1448 local: false,
1449 }
1450 );
1451
1452 let signed_create = ReleaseOptions::parse([
1453 "--signer".into(),
1454 "signer.rid".into(),
1455 "--name".into(),
1456 "pkg".into(),
1457 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1458 "create".into(),
1459 "v2".into(),
1460 "dist".into(),
1461 ])
1462 .unwrap();
1463 assert_eq!(
1464 signed_create.command,
1465 ReleaseCommand::Create {
1466 tag: "v2".into(),
1467 artifacts_dir: PathBuf::from("dist"),
1468 notes_path: None,
1469 signer_path: Some(PathBuf::from("signer.rid")),
1470 package_name: Some("pkg".into()),
1471 local: false,
1472 }
1473 );
1474
1475 let local_create = ReleaseOptions::parse([
1476 "--local".into(),
1477 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1478 "create".into(),
1479 "v3:dist".into(),
1480 ])
1481 .unwrap();
1482 assert!(matches!(
1483 local_create.command,
1484 ReleaseCommand::Create { local: true, .. }
1485 ));
1486
1487 let fetch = ReleaseOptions::parse([
1488 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1489 "fetch".into(),
1490 "v1:app.bin".into(),
1491 ])
1492 .unwrap();
1493 assert_eq!(
1494 fetch.command,
1495 ReleaseCommand::Fetch {
1496 target: "v1:app.bin".into(),
1497 required_signer: None,
1498 offline: false,
1499 }
1500 );
1501
1502 let offline_fetch =
1503 ReleaseOptions::parse(["--offline".into(), "pkg_v1.rsm".into(), "fetch".into()])
1504 .unwrap();
1505 assert_eq!(
1506 offline_fetch.command,
1507 ReleaseCommand::Fetch {
1508 target: "latest:all".into(),
1509 required_signer: None,
1510 offline: true,
1511 }
1512 );
1513
1514 let verify = ReleaseOptions::parse([
1515 "--signer".into(),
1516 "00112233445566778899aabbccddeeff".into(),
1517 "pkg_v1.rsm".into(),
1518 "verify".into(),
1519 "v1:app.bin".into(),
1520 ])
1521 .unwrap();
1522 assert_eq!(
1523 verify.command,
1524 ReleaseCommand::Fetch {
1525 target: "v1:app.bin".into(),
1526 required_signer: Some("00112233445566778899aabbccddeeff".into()),
1527 offline: true,
1528 }
1529 );
1530
1531 let delete = ReleaseOptions::parse([
1532 "-y".into(),
1533 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1534 "delete".into(),
1535 "v1".into(),
1536 ])
1537 .unwrap();
1538 assert_eq!(
1539 delete.command,
1540 ReleaseCommand::Delete {
1541 tag: "v1".into(),
1542 yes: true
1543 }
1544 );
1545
1546 let latest = ReleaseOptions::parse([
1547 "--yes".into(),
1548 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1549 "latest".into(),
1550 "v1".into(),
1551 ])
1552 .unwrap();
1553 assert_eq!(
1554 latest.command,
1555 ReleaseCommand::Latest {
1556 tag: "v1".into(),
1557 yes: true
1558 }
1559 );
1560 }
1561
1562 #[test]
1563 fn create_sends_init_artifacts_and_finalize() {
1564 let tmp = tempfile::tempdir().unwrap();
1565 fs::write(tmp.path().join("RELEASE.md"), "# Notes\n").unwrap();
1566 fs::write(tmp.path().join("b.bin"), b"b").unwrap();
1567 fs::write(tmp.path().join("a.bin"), b"a").unwrap();
1568 let mut fake = FakeTransport {
1569 responses: vec![Vec::new(), Vec::new(), Vec::new(), Vec::new()],
1570 resource_responses: Vec::new(),
1571 requests: Vec::new(),
1572 timeouts: Vec::new(),
1573 resource_timeouts: Vec::new(),
1574 };
1575 let mut out = Vec::new();
1576
1577 create_release(
1578 &mut fake,
1579 CreateReleaseOptions {
1580 tag: "v1",
1581 artifacts_dir: tmp.path(),
1582 notes_path: None,
1583 signer_path: None,
1584 package_name: None,
1585 origin_hash: None,
1586 origin_path: None,
1587 local: false,
1588 },
1589 &mut out,
1590 )
1591 .unwrap();
1592
1593 assert_eq!(fake.requests.len(), 4);
1594 assert_eq!(
1595 fake.timeouts,
1596 vec![
1597 RELEASE_TIMEOUT_SHORT,
1598 RELEASE_TIMEOUT_LONG,
1599 RELEASE_TIMEOUT_LONG,
1600 RELEASE_TIMEOUT_MEDIUM
1601 ]
1602 );
1603 assert_eq!(
1604 fake.requests[0]
1605 .map_get("operation")
1606 .and_then(Value::as_str),
1607 Some("create")
1608 );
1609 assert_eq!(
1610 fake.requests[0].map_get("step").and_then(Value::as_str),
1611 Some("init")
1612 );
1613 assert_eq!(
1614 fake.requests[0].map_get("notes").and_then(Value::as_str),
1615 Some("# Notes\n")
1616 );
1617 assert_eq!(
1618 fake.requests[1]
1619 .map_get("artifact_name")
1620 .and_then(Value::as_str),
1621 Some("a.bin")
1622 );
1623 assert_eq!(
1624 fake.requests[2]
1625 .map_get("artifact_name")
1626 .and_then(Value::as_str),
1627 Some("b.bin")
1628 );
1629 assert_eq!(
1630 fake.requests[3].map_get("step").and_then(Value::as_str),
1631 Some("finalize")
1632 );
1633 let out = String::from_utf8(out).unwrap();
1634 assert!(out.contains("Initializing release v1"));
1635 assert!(out.contains("Uploading a.bin (1/2, 1 bytes)"));
1636 assert!(out.contains("Uploading b.bin (2/2, 1 bytes)"));
1637 assert!(out.contains("Finalizing release v1"));
1638 assert!(out.ends_with("Created release v1 with 2 artifact(s)\n"));
1639 }
1640
1641 #[test]
1642 fn create_with_signer_writes_artifact_signatures_and_manifest() {
1643 let tmp = tempfile::tempdir().unwrap();
1644 let signer_dir = tempfile::tempdir().unwrap();
1645 fs::write(tmp.path().join("RELEASE.md"), "# Notes\n").unwrap();
1646 fs::write(tmp.path().join("app.bin"), b"app").unwrap();
1647 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1648 let signer_path = signer_dir.path().join("signer.rid");
1649 rns_net::storage::save_identity(&signer, &signer_path).unwrap();
1650 let mut fake = FakeTransport {
1651 responses: vec![Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()],
1652 resource_responses: Vec::new(),
1653 requests: Vec::new(),
1654 timeouts: Vec::new(),
1655 resource_timeouts: Vec::new(),
1656 };
1657 let mut out = Vec::new();
1658
1659 create_release(
1660 &mut fake,
1661 CreateReleaseOptions {
1662 tag: "v1",
1663 artifacts_dir: tmp.path(),
1664 notes_path: None,
1665 signer_path: Some(&signer_path),
1666 package_name: Some("pkg"),
1667 origin_hash: Some(&[0x11; 16]),
1668 origin_path: Some("group/repo"),
1669 local: false,
1670 },
1671 &mut out,
1672 )
1673 .unwrap();
1674
1675 assert!(tmp.path().join("app.bin.rsg").exists());
1676 assert!(tmp.path().join("manifest.rsm").exists());
1677 assert_eq!(fake.requests.len(), 5);
1678 let uploaded = fake.requests[1..4]
1679 .iter()
1680 .filter_map(|request| request.map_get("artifact_name").and_then(Value::as_str))
1681 .collect::<Vec<_>>();
1682 assert_eq!(uploaded, vec!["app.bin", "app.bin.rsg", "manifest.rsm"]);
1683
1684 let manifest = rsg_envelope(&tmp.path().join("manifest.rsm"));
1685 let meta = manifest.map_get("meta").unwrap();
1686 assert_eq!(meta.map_get("name").and_then(Value::as_str), Some("pkg"));
1687 assert_eq!(meta.map_get("version").and_then(Value::as_str), Some("v1"));
1688 assert!(meta.map_get("released").and_then(Value::as_str).is_some());
1689 assert_eq!(
1690 meta.map_get("origin").and_then(Value::as_bin),
1691 Some(&[0x11; 16][..])
1692 );
1693 assert_eq!(
1694 meta.map_get("path").and_then(Value::as_str),
1695 Some("group/repo")
1696 );
1697 assert!(meta.map_get("commit").is_some_and(Value::is_nil));
1698 let artifacts = meta.map_get("artifacts").and_then(Value::as_array).unwrap();
1699 assert_eq!(artifacts.len(), 1);
1700 let artifact = &artifacts[0];
1701 assert_eq!(
1702 artifact.map_get("name").and_then(Value::as_str),
1703 Some("app.bin")
1704 );
1705 assert!(artifact.map_get("rsg").and_then(Value::as_bin).is_some());
1706
1707 let artifact_sig = rsg_envelope(&tmp.path().join("app.bin.rsg"));
1708 assert!(artifact_sig
1709 .map_get("meta")
1710 .unwrap()
1711 .map_get("timestamp")
1712 .and_then(Value::as_integer)
1713 .is_some());
1714 }
1715
1716 #[test]
1717 fn signed_release_manifest_records_tag_commit_hash() {
1718 let tmp = tempfile::tempdir().unwrap();
1719 let signer_dir = tempfile::tempdir().unwrap();
1720 let repo = tempfile::tempdir().unwrap();
1721 fs::write(tmp.path().join("app.bin"), b"app").unwrap();
1722 fs::write(repo.path().join("tracked.txt"), b"tracked").unwrap();
1723 run_git(repo.path(), &["init"]);
1724 run_git(repo.path(), &["add", "tracked.txt"]);
1725 run_git(
1726 repo.path(),
1727 &[
1728 "-c",
1729 "user.name=Tester",
1730 "-c",
1731 "user.email=test@example.invalid",
1732 "commit",
1733 "-m",
1734 "initial",
1735 ],
1736 );
1737 run_git(repo.path(), &["tag", "v1"]);
1738 let commit = run_git(repo.path(), &["rev-parse", "HEAD"]);
1739
1740 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1741 let signer_path = signer_dir.path().join("signer.rid");
1742 rns_net::storage::save_identity(&signer, &signer_path).unwrap();
1743 sign_release_artifacts_in_repo(
1744 tmp.path(),
1745 &[ArtifactFile {
1746 name: "app.bin".into(),
1747 path: tmp.path().join("app.bin"),
1748 }],
1749 "v1",
1750 "# Notes\n",
1751 &signer_path,
1752 Some("pkg"),
1753 Some(&[0x11; 16]),
1754 Some("group/repo"),
1755 repo.path(),
1756 )
1757 .unwrap();
1758
1759 let manifest = rsg_envelope(&tmp.path().join("manifest.rsm"));
1760 let meta = manifest.map_get("meta").unwrap();
1761 assert_eq!(
1762 meta.map_get("commit").and_then(Value::as_str),
1763 Some(commit.trim())
1764 );
1765 }
1766
1767 #[test]
1768 fn local_create_signs_release_without_uploading() {
1769 let tmp = tempfile::tempdir().unwrap();
1770 let signer_dir = tempfile::tempdir().unwrap();
1771 fs::write(tmp.path().join("RELEASE.md"), "# Notes\n").unwrap();
1772 fs::write(tmp.path().join("app.bin"), b"app").unwrap();
1773 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1774 let signer_path = signer_dir.path().join("signer.rid");
1775 rns_net::storage::save_identity(&signer, &signer_path).unwrap();
1776 let mut fake = FakeTransport::default();
1777 let mut out = Vec::new();
1778
1779 create_release(
1780 &mut fake,
1781 CreateReleaseOptions {
1782 tag: "v1",
1783 artifacts_dir: tmp.path(),
1784 notes_path: None,
1785 signer_path: Some(&signer_path),
1786 package_name: Some("pkg"),
1787 origin_hash: Some(&[0x22; 16]),
1788 origin_path: Some("group/repo"),
1789 local: true,
1790 },
1791 &mut out,
1792 )
1793 .unwrap();
1794
1795 assert!(tmp.path().join("app.bin.rsg").exists());
1796 assert!(tmp.path().join("manifest.rsm").exists());
1797 assert!(fake.requests.is_empty());
1798 let manifest = rsg_envelope(&tmp.path().join("manifest.rsm"));
1799 let meta = manifest.map_get("meta").unwrap();
1800 assert_eq!(
1801 meta.map_get("origin").and_then(Value::as_bin),
1802 Some(&[0x22; 16][..])
1803 );
1804 assert_eq!(
1805 meta.map_get("path").and_then(Value::as_str),
1806 Some("group/repo")
1807 );
1808 let out = String::from_utf8(out).unwrap();
1809 assert!(out.contains("Local release pkg:v1 generated successfully"));
1810 }
1811
1812 fn rsg_envelope(path: &Path) -> Value {
1813 let data = fs::read(path).unwrap();
1814 msgpack::unpack_exact(&data[64..]).unwrap()
1815 }
1816
1817 #[test]
1818 fn missing_requested_artifact_returns_clear_error() {
1819 let artifacts = vec![ManifestArtifact {
1820 name: "app.tar.gz".into(),
1821 rsg: Vec::new(),
1822 }];
1823
1824 let err = select_manifest_artifacts(&artifacts, "missing.whl").unwrap_err();
1825
1826 assert_eq!(
1827 err.to_string(),
1828 "No available artifacts specified for fetch"
1829 );
1830 }
1831
1832 #[test]
1833 fn wildcard_artifact_selection_matches_shell_style_patterns() {
1834 let artifacts = vec![
1835 ManifestArtifact {
1836 name: "app-1.2.3-linux-x86_64.tar.gz".into(),
1837 rsg: Vec::new(),
1838 },
1839 ManifestArtifact {
1840 name: "app-1.2.4-linux-aarch64.tar.gz".into(),
1841 rsg: Vec::new(),
1842 },
1843 ManifestArtifact {
1844 name: "app-1.2.3-py3-none-any.whl".into(),
1845 rsg: Vec::new(),
1846 },
1847 ManifestArtifact {
1848 name: "source_1.2.3.tgz".into(),
1849 rsg: Vec::new(),
1850 },
1851 ];
1852
1853 let wheel = select_manifest_artifacts(&artifacts, "*-py3-*.whl").unwrap();
1854 assert_eq!(
1855 wheel
1856 .iter()
1857 .map(|artifact| artifact.name.as_str())
1858 .collect::<Vec<_>>(),
1859 vec!["app-1.2.3-py3-none-any.whl"]
1860 );
1861
1862 let patch = select_manifest_artifacts(&artifacts, "app-1.2.?-linux-*.tar.gz").unwrap();
1863 assert_eq!(patch.len(), 2);
1864
1865 let class = select_manifest_artifacts(&artifacts, "app-1.2.[34]-linux-*.tar.gz").unwrap();
1866 assert_eq!(class.len(), 2);
1867
1868 let negated = select_manifest_artifacts(&artifacts, "app-1.2.[!4]-linux-*.tar.gz").unwrap();
1869 assert_eq!(negated[0].name, "app-1.2.3-linux-x86_64.tar.gz");
1870
1871 let source = select_manifest_artifacts(&artifacts, "source_*.tgz").unwrap();
1872 assert_eq!(source[0].name, "source_1.2.3.tgz");
1873 }
1874
1875 #[test]
1876 fn fetch_validates_release_target_before_requesting() {
1877 let mut fake = FakeTransport::default();
1878 let err = run_release_command(
1879 &mut fake,
1880 &ReleaseCommand::Fetch {
1881 target: "v1".into(),
1882 required_signer: None,
1883 offline: false,
1884 },
1885 Vec::new(),
1886 )
1887 .unwrap_err();
1888 assert!(err.to_string().contains("Invalid release specification"));
1889 assert!(fake.requests.is_empty());
1890 }
1891
1892 #[test]
1893 fn fetch_validates_manifest_and_downloads_artifact() {
1894 let tmp = tempfile::tempdir().unwrap();
1895 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1896 let artifact_rsg = create_rsg(&signer, b"app", false, Vec::new()).unwrap();
1897 let manifest = create_rsg(
1898 &signer,
1899 b"notes",
1900 true,
1901 vec![
1902 ("name".into(), Value::Str("pkg".into())),
1903 ("version".into(), Value::Str("v1".into())),
1904 ("origin".into(), Value::Bin(vec![0x33; 16])),
1905 ("path".into(), Value::Str("group/repo".into())),
1906 (
1907 "artifacts".into(),
1908 Value::Array(vec![Value::Map(vec![
1909 (Value::Str("name".into()), Value::Str("app.bin".into())),
1910 (Value::Str("rsg".into()), Value::Bin(artifact_rsg)),
1911 ])]),
1912 ),
1913 ],
1914 )
1915 .unwrap();
1916 let required = crate::util::hex(signer.hash());
1917 let mut fake = FakeTransport {
1918 responses: Vec::new(),
1919 resource_responses: vec![manifest, b"app".to_vec()],
1920 requests: Vec::new(),
1921 timeouts: Vec::new(),
1922 resource_timeouts: Vec::new(),
1923 };
1924 let mut out = Vec::new();
1925
1926 fetch_release_into(
1927 &mut fake,
1928 "v1:app.bin",
1929 Some(&required),
1930 tmp.path(),
1931 &mut out,
1932 )
1933 .unwrap();
1934
1935 assert_eq!(fs::read(tmp.path().join("app.bin")).unwrap(), b"app");
1936 assert!(tmp.path().join("pkg_v1.rsm").exists());
1937 assert_eq!(fake.requests.len(), 2);
1938 assert_eq!(
1939 fake.resource_timeouts,
1940 vec![RELEASE_TIMEOUT_LONG, RELEASE_TIMEOUT_LONG]
1941 );
1942 assert_eq!(
1943 fake.requests[0].map_get("artifact").and_then(Value::as_str),
1944 Some("manifest.rsm")
1945 );
1946 assert_eq!(
1947 fake.requests[1].map_get("artifact").and_then(Value::as_str),
1948 Some("app.bin")
1949 );
1950 let out = String::from_utf8(out).unwrap();
1951 assert!(out.contains("Release manifest validated"));
1952 assert!(out.contains("Fetched app.bin"));
1953 }
1954
1955 #[test]
1956 fn offline_manifest_verification_checks_local_artifacts() {
1957 let tmp = tempfile::tempdir().unwrap();
1958 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1959 fs::write(tmp.path().join("app.bin"), b"app").unwrap();
1960 let artifact_rsg = create_rsg(&signer, b"app", false, Vec::new()).unwrap();
1961 let manifest = create_rsg(
1962 &signer,
1963 b"notes",
1964 true,
1965 vec![
1966 ("name".into(), Value::Str("pkg".into())),
1967 ("version".into(), Value::Str("v1".into())),
1968 ("origin".into(), Value::Bin(vec![0x55; 16])),
1969 ("path".into(), Value::Str("group/repo".into())),
1970 (
1971 "artifacts".into(),
1972 Value::Array(vec![Value::Map(vec![
1973 (Value::Str("name".into()), Value::Str("app.bin".into())),
1974 (Value::Str("rsg".into()), Value::Bin(artifact_rsg)),
1975 ])]),
1976 ),
1977 ],
1978 )
1979 .unwrap();
1980 let manifest_path = tmp.path().join("pkg_v1.rsm");
1981 fs::write(&manifest_path, manifest).unwrap();
1982 let required = crate::util::hex(signer.hash());
1983 let mut out = Vec::new();
1984
1985 verify_release_manifest_offline(&manifest_path, "latest:all", Some(&required), &mut out)
1986 .unwrap();
1987
1988 let out = String::from_utf8(out).unwrap();
1989 assert!(out.contains("Release manifest validated"));
1990 assert!(out.contains("File app.bin validated against manifest"));
1991 assert!(out.contains("All files validated"));
1992 }
1993
1994 #[test]
1995 fn local_manifest_path_resolves_remote_from_signed_metadata() {
1996 let tmp = tempfile::tempdir().unwrap();
1997 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1998 let manifest = create_rsg(
1999 &signer,
2000 b"notes",
2001 true,
2002 vec![
2003 ("name".into(), Value::Str("pkg".into())),
2004 ("version".into(), Value::Str("v1".into())),
2005 ("origin".into(), Value::Bin(vec![0x44; 16])),
2006 ("path".into(), Value::Str("group/repo".into())),
2007 ("artifacts".into(), Value::Array(Vec::new())),
2008 ],
2009 )
2010 .unwrap();
2011 let manifest_path = tmp.path().join("pkg_v1.rsm");
2012 fs::write(&manifest_path, manifest).unwrap();
2013 let required = crate::util::hex(signer.hash());
2014
2015 let remote = release_remote_from_manifest_file(&manifest_path, Some(&required)).unwrap();
2016
2017 assert_eq!(remote, "rns://44444444444444444444444444444444/group/repo");
2018 }
2019
2020 #[test]
2021 fn list_and_view_format_msgpack_responses() {
2022 let release = Value::Map(vec![
2023 (Value::Str("tag".into()), Value::Str("v1".into())),
2024 (Value::Str("status".into()), Value::Str("published".into())),
2025 (Value::Str("artifacts".into()), Value::UInt(2)),
2026 (
2027 Value::Str("preview".into()),
2028 Value::Str("First\nSecond line should be hidden".into()),
2029 ),
2030 ]);
2031 let list_body = Value::Map(vec![
2032 (
2033 Value::Str("releases".into()),
2034 Value::Array(vec![release.clone()]),
2035 ),
2036 (Value::Str("latest".into()), Value::Str("v1".into())),
2037 ]);
2038 let mut out = Vec::new();
2039 print_release_list(&list_body, &mut out).unwrap();
2040 let list = String::from_utf8(out).unwrap();
2041 assert!(list.contains("v1"));
2042 assert!(list.contains("published"));
2043 assert!(list.contains("First"));
2044 assert!(!list.contains("Second line should be hidden"));
2045 assert!(list.contains("The latest release is: v1"));
2046
2047 let mut out = Vec::new();
2048 print_release_list(&Value::Array(vec![release]), &mut out).unwrap();
2049 let legacy_list = String::from_utf8(out).unwrap();
2050 assert!(legacy_list.contains("v1"));
2051
2052 let view_body = Value::Map(vec![
2053 (Value::Str("tag".into()), Value::Str("v1".into())),
2054 (Value::Str("status".into()), Value::Str("published".into())),
2055 (Value::Str("thanks".into()), Value::UInt(3)),
2056 (Value::Str("notes".into()), Value::Str("Notes".into())),
2057 (
2058 Value::Str("artifacts".into()),
2059 Value::Array(vec![Value::Map(vec![
2060 (Value::Str("name".into()), Value::Str("dist.tar".into())),
2061 (Value::Str("size".into()), Value::UInt(9)),
2062 ])]),
2063 ),
2064 ]);
2065 let mut out = Vec::new();
2066 print_release_view(&view_body, &mut out).unwrap();
2067 let view = String::from_utf8(out).unwrap();
2068 assert!(view.contains("Release : v1"));
2069 assert!(view.contains("Thanks : 3"));
2070 assert!(view.contains("dist.tar (9 bytes)"));
2071 }
2072
2073 #[test]
2074 fn delete_requires_explicit_yes() {
2075 let mut fake = FakeTransport::default();
2076 let err = run_release_command(
2077 &mut fake,
2078 &ReleaseCommand::Delete {
2079 tag: "v1".into(),
2080 yes: false,
2081 },
2082 Vec::new(),
2083 )
2084 .unwrap_err();
2085 assert!(err.to_string().contains("--yes"));
2086 assert!(fake.requests.is_empty());
2087 }
2088
2089 #[test]
2090 fn delete_with_yes_sends_delete_request() {
2091 let mut fake = FakeTransport {
2092 responses: vec![Vec::new()],
2093 resource_responses: Vec::new(),
2094 requests: Vec::new(),
2095 timeouts: Vec::new(),
2096 resource_timeouts: Vec::new(),
2097 };
2098 let mut out = Vec::new();
2099 run_release_command(
2100 &mut fake,
2101 &ReleaseCommand::Delete {
2102 tag: "v1".into(),
2103 yes: true,
2104 },
2105 &mut out,
2106 )
2107 .unwrap();
2108
2109 assert_eq!(fake.requests.len(), 1);
2110 assert_eq!(
2111 fake.requests[0]
2112 .map_get("operation")
2113 .and_then(Value::as_str),
2114 Some("delete")
2115 );
2116 assert_eq!(
2117 fake.requests[0].map_get("tag").and_then(Value::as_str),
2118 Some("v1")
2119 );
2120 assert_eq!(String::from_utf8(out).unwrap(), "Deleted release v1\n");
2121 }
2122
2123 #[test]
2124 fn latest_requires_yes_and_sends_latest_request() {
2125 let mut fake = FakeTransport::default();
2126 let err = run_release_command(
2127 &mut fake,
2128 &ReleaseCommand::Latest {
2129 tag: "v1".into(),
2130 yes: false,
2131 },
2132 Vec::new(),
2133 )
2134 .unwrap_err();
2135 assert!(err.to_string().contains("--yes"));
2136 assert!(fake.requests.is_empty());
2137
2138 let mut fake = FakeTransport {
2139 responses: vec![Vec::new()],
2140 resource_responses: Vec::new(),
2141 requests: Vec::new(),
2142 timeouts: Vec::new(),
2143 resource_timeouts: Vec::new(),
2144 };
2145 let mut out = Vec::new();
2146 run_release_command(
2147 &mut fake,
2148 &ReleaseCommand::Latest {
2149 tag: "v1".into(),
2150 yes: true,
2151 },
2152 &mut out,
2153 )
2154 .unwrap();
2155
2156 assert_eq!(fake.requests.len(), 1);
2157 assert_eq!(
2158 fake.requests[0]
2159 .map_get("operation")
2160 .and_then(Value::as_str),
2161 Some("latest")
2162 );
2163 assert_eq!(
2164 fake.requests[0].map_get("tag").and_then(Value::as_str),
2165 Some("v1")
2166 );
2167 assert_eq!(
2168 String::from_utf8(out).unwrap(),
2169 "Release v1 set as latest\n"
2170 );
2171 }
2172
2173 fn run_git(repo_path: &Path, args: &[&str]) -> String {
2174 let output = Command::new("git")
2175 .args(args)
2176 .current_dir(repo_path)
2177 .output()
2178 .unwrap();
2179 assert!(
2180 output.status.success(),
2181 "git {:?} failed: {}",
2182 args,
2183 String::from_utf8_lossy(&output.stderr)
2184 );
2185 String::from_utf8(output.stdout).unwrap()
2186 }
2187}