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 if tag.is_empty() || tag.starts_with('-') {
663 return Ok(None);
664 }
665 let output = Command::new("git")
666 .arg("rev-list")
667 .arg("-n")
668 .arg("1")
669 .arg(tag)
670 .current_dir(repo_path)
671 .output()
672 .map_err(|e| Error::msg(format!("could not run git to resolve tag {tag}: {e}")))?;
673 if !output.status.success() {
674 return Ok(None);
675 }
676 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
677 Ok((!commit.is_empty()).then_some(commit))
678}
679
680fn fetch_release_into(
681 transport: &mut impl ReleaseTransport,
682 target: &str,
683 required_signer: Option<&str>,
684 output_dir: &Path,
685 mut output: impl Write,
686) -> Result<()> {
687 let (tag, requested_artifact) = parse_fetch_target(target)?;
688 let required_signer = required_signer.map(parse_hex_16).transpose()?;
689 let manifest_bytes = transport.request_resource_with_timeout(
690 request(
691 "fetch",
692 &[
693 ("tag", Value::Str(tag.clone())),
694 ("artifact", Value::Str("manifest.rsm".into())),
695 ],
696 ),
697 RELEASE_TIMEOUT_LONG,
698 )?;
699 let manifest = validate_embedded_manifest(&manifest_bytes, required_signer)?;
700 validate_release_rsm_structure(&manifest.envelope)?;
701 writeln!(
702 output,
703 "Release manifest validated, signed by {}",
704 crate::util::hex(&manifest.signer_hash)
705 )?;
706 let manifest_name = manifest_output_name(&manifest.envelope)?;
707 fs::write(output_dir.join(manifest_name), &manifest_bytes)?;
708 let artifacts = manifest_artifacts(&manifest.envelope)?;
709 if artifacts.is_empty() {
710 return Err(Error::msg("Release manifest contains no artifacts"));
711 }
712 let selected = select_manifest_artifacts(&artifacts, &requested_artifact)?;
713 fs::create_dir_all(output_dir)?;
714
715 for artifact in selected {
716 let output_path = output_dir.join(&artifact.name);
717 if output_path.exists() {
718 let existing = fs::read(&output_path)?;
719 if validate_rsg(&artifact.rsg, &existing, required_signer).is_ok() {
720 writeln!(
721 output,
722 "Existing file {} validated, not fetching again",
723 artifact.name
724 )?;
725 continue;
726 }
727 writeln!(
728 output,
729 "Existing file {} does not match manifest, fetching and overwriting",
730 artifact.name
731 )?;
732 }
733 let data = transport.request_resource_with_timeout(
734 request(
735 "fetch",
736 &[
737 ("tag", Value::Str(tag.clone())),
738 ("artifact", Value::Str(artifact.name.clone())),
739 ],
740 ),
741 RELEASE_TIMEOUT_LONG,
742 )?;
743 validate_rsg(&artifact.rsg, &data, required_signer)?;
744 fs::write(&output_path, data)?;
745 writeln!(output, "Fetched {}", artifact.name)?;
746 }
747 Ok(())
748}
749
750fn verify_release_manifest_offline(
751 manifest_path: &Path,
752 target: &str,
753 required_signer: Option<&str>,
754 mut output: impl Write,
755) -> Result<()> {
756 let (_tag, requested_artifact) = parse_fetch_target(target)?;
757 let required_signer = required_signer.map(parse_hex_16).transpose()?;
758 let manifest_bytes = fs::read(manifest_path)?;
759 let manifest = validate_embedded_manifest(&manifest_bytes, required_signer)?;
760 validate_release_rsm_structure(&manifest.envelope)?;
761 writeln!(
762 output,
763 "Release manifest validated, signed by {}",
764 crate::util::hex(&manifest.signer_hash)
765 )?;
766 let artifacts = manifest_artifacts(&manifest.envelope)?;
767 if artifacts.is_empty() {
768 return Err(Error::msg("Release manifest contains no artifacts"));
769 }
770 let selected = select_manifest_artifacts(&artifacts, &requested_artifact)?;
771 let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
772 let mut valid_count = 0usize;
773 let plural = if selected.len() == 1 { "" } else { "s" };
774 writeln!(output, "Validating {} artifact{plural}...", selected.len())?;
775 for artifact in &selected {
776 let path = base_dir.join(&artifact.name);
777 if !path.exists() {
778 writeln!(
779 output,
780 " File {} from manifest does not exist locally, cannot validate",
781 artifact.name
782 )?;
783 continue;
784 }
785 let data = fs::read(&path)?;
786 if validate_rsg(&artifact.rsg, &data, required_signer).is_ok() {
787 writeln!(
788 output,
789 " File {} validated against manifest {}",
790 artifact.name,
791 manifest_path.display()
792 )?;
793 valid_count += 1;
794 } else {
795 writeln!(output, " File {} does not match manifest", artifact.name)?;
796 }
797 }
798 if valid_count == selected.len() {
799 writeln!(
800 output,
801 "
802All files validated"
803 )?;
804 Ok(())
805 } else {
806 writeln!(
807 output,
808 "
809Release is not valid"
810 )?;
811 Err(Error::msg("Release is not valid"))
812 }
813}
814
815fn resolved_release_remote(options: &ReleaseOptions) -> Result<String> {
816 if let ReleaseCommand::Fetch {
817 required_signer, ..
818 } = &options.command
819 {
820 if let Some(path) = local_manifest_path(&options.remote) {
821 return release_remote_from_manifest_file(&path, required_signer.as_deref());
822 }
823 }
824 Ok(options.remote.clone())
825}
826
827fn local_manifest_path(remote: &str) -> Option<PathBuf> {
828 let path = PathBuf::from(remote);
829 (path.extension().and_then(|ext| ext.to_str()) == Some("rsm") && path.is_file()).then_some(path)
830}
831
832fn release_remote_from_manifest_file(path: &Path, required_signer: Option<&str>) -> Result<String> {
833 let required_signer = required_signer.map(parse_hex_16).transpose()?;
834 let manifest_bytes = fs::read(path)?;
835 let manifest = validate_embedded_manifest(&manifest_bytes, required_signer)?;
836 let info = validate_release_rsm_structure(&manifest.envelope)?;
837 Ok(format!(
838 "rns://{}/{}",
839 crate::util::hex(&info.origin),
840 info.path
841 ))
842}
843
844fn parse_fetch_target(target: &str) -> Result<(String, String)> {
845 let (tag, artifact) = target
846 .split_once(':')
847 .ok_or_else(|| Error::msg("Invalid release specification"))?;
848 if tag.is_empty() || artifact.is_empty() {
849 return Err(Error::msg("Invalid release specification"));
850 }
851 Ok((tag.to_string(), artifact.to_string()))
852}
853
854#[derive(Debug)]
855struct ValidRsg {
856 envelope: Value,
857 signer_hash: [u8; 16],
858}
859
860#[derive(Debug, Clone)]
861struct ManifestArtifact {
862 name: String,
863 rsg: Vec<u8>,
864}
865
866fn validate_embedded_manifest(rsg: &[u8], required_signer: Option<[u8; 16]>) -> Result<ValidRsg> {
867 let envelope = rsg_envelope(rsg)?;
868 let message = rsg_embedded_message(&envelope)
869 .ok_or_else(|| Error::msg("No embedded message in release manifest"))?;
870 validate_rsg_parts(rsg, &message, required_signer, Some(envelope))
871}
872
873fn validate_rsg(rsg: &[u8], message: &[u8], required_signer: Option<[u8; 16]>) -> Result<ValidRsg> {
874 validate_rsg_parts(rsg, message, required_signer, None)
875}
876
877fn validate_rsg_parts(
878 rsg: &[u8],
879 message: &[u8],
880 required_signer: Option<[u8; 16]>,
881 envelope: Option<Value>,
882) -> Result<ValidRsg> {
883 const SIG_LEN: usize = 64;
884 if rsg.len() <= SIG_LEN {
885 return Err(Error::msg("invalid RSG"));
886 }
887 let signature: [u8; SIG_LEN] = rsg[..SIG_LEN].try_into().unwrap();
888 let envelope_bytes = &rsg[SIG_LEN..];
889 let envelope = match envelope {
890 Some(envelope) => envelope,
891 None => msgpack::unpack_exact(envelope_bytes)
892 .map_err(|e| Error::msg(format!("invalid RSG envelope: {e}")))?,
893 };
894 if envelope.map_get("hashtype").and_then(Value::as_str) != Some("sha256") {
895 return Err(Error::msg("unsupported RSG hash type"));
896 }
897 let signed_hash = envelope
898 .map_get("hash")
899 .and_then(Value::as_bin)
900 .ok_or_else(|| Error::msg("RSG is missing hash"))?;
901 let calculated_hash = sha256(message);
902 if signed_hash != calculated_hash.as_slice() {
903 return Err(Error::msg("RSG hash does not match message"));
904 }
905 let meta = envelope
906 .map_get("meta")
907 .ok_or_else(|| Error::msg("RSG is missing metadata"))?;
908 let pubkey = meta
909 .map_get("pubkey")
910 .and_then(Value::as_bin)
911 .ok_or_else(|| Error::msg("RSG is missing public key"))?;
912 let public_key: [u8; 64] = pubkey
913 .try_into()
914 .map_err(|_| Error::msg("RSG public key has invalid length"))?;
915 let identity = Identity::from_public_key(&public_key)
916 .map_err(|_| Error::msg("RSG public key is invalid"))?;
917 let signer_hash = *identity.hash();
918 let meta_signer = meta
919 .map_get("signer")
920 .and_then(Value::as_bin)
921 .ok_or_else(|| Error::msg("RSG is missing signer"))?;
922 if meta_signer != signer_hash.as_slice() {
923 return Err(Error::msg("RSG signer does not match public key"));
924 }
925 if let Some(required) = required_signer {
926 if signer_hash != required {
927 return Err(Error::msg(format!(
928 "RSG is not signed by required signer {}",
929 crate::util::hex(&required)
930 )));
931 }
932 }
933 if !identity.verify(&signature, envelope_bytes) {
934 return Err(Error::msg("RSG signature verification failed"));
935 }
936 Ok(ValidRsg {
937 envelope,
938 signer_hash,
939 })
940}
941
942fn rsg_envelope(rsg: &[u8]) -> Result<Value> {
943 const SIG_LEN: usize = 64;
944 if rsg.len() <= SIG_LEN {
945 return Err(Error::msg("invalid RSG"));
946 }
947 msgpack::unpack_exact(&rsg[SIG_LEN..])
948 .map_err(|e| Error::msg(format!("invalid RSG envelope: {e}")))
949}
950
951fn rsg_embedded_message(value: &Value) -> Option<Vec<u8>> {
952 value
953 .map_get("message")
954 .and_then(Value::as_bin)
955 .map(ToOwned::to_owned)
956 .or_else(|| {
957 value
958 .map_get("message")
959 .and_then(Value::as_str)
960 .map(|message| message.as_bytes().to_vec())
961 })
962}
963
964#[derive(Debug, Clone, PartialEq, Eq)]
965struct ReleaseManifestInfo {
966 name: String,
967 version: String,
968 origin: [u8; 16],
969 path: String,
970}
971
972fn validate_release_rsm_structure(manifest: &Value) -> Result<ReleaseManifestInfo> {
973 let meta = manifest
974 .map_get("meta")
975 .ok_or_else(|| Error::msg("No release metadata in manifest"))?;
976 let name = meta
977 .map_get("name")
978 .and_then(Value::as_str)
979 .and_then(sanitize_manifest_component)
980 .ok_or_else(|| Error::msg("Incomplete package data in manifest"))?;
981 let version = meta
982 .map_get("version")
983 .and_then(Value::as_str)
984 .and_then(sanitize_manifest_component)
985 .ok_or_else(|| Error::msg("Incomplete package data in manifest"))?;
986 let origin = meta
987 .map_get("origin")
988 .ok_or_else(|| Error::msg("Incomplete release origin data in manifest"))?;
989 let origin = origin
990 .as_bin()
991 .ok_or_else(|| Error::msg("Invalid origin hash in manifest"))?;
992 let origin: [u8; 16] = origin
993 .try_into()
994 .map_err(|_| Error::msg("Invalid origin hash length in manifest"))?;
995 let path = meta
996 .map_get("path")
997 .and_then(Value::as_str)
998 .filter(|path| !path.is_empty())
999 .ok_or_else(|| Error::msg("Incomplete release origin data in manifest"))?;
1000 Ok(ReleaseManifestInfo {
1001 name,
1002 version,
1003 origin,
1004 path: path.to_string(),
1005 })
1006}
1007
1008fn manifest_output_name(manifest: &Value) -> Result<String> {
1009 let info = validate_release_rsm_structure(manifest)?;
1010 Ok(format!("{}_{}.rsm", info.name, info.version))
1011}
1012
1013fn sanitize_manifest_component(value: &str) -> Option<String> {
1014 (!value.is_empty()
1015 && value != "."
1016 && value != ".."
1017 && !value.contains('/')
1018 && !value.contains('\\'))
1019 .then(|| value.to_string())
1020}
1021
1022fn manifest_artifacts(manifest: &Value) -> Result<Vec<ManifestArtifact>> {
1023 let artifacts = manifest
1024 .map_get("meta")
1025 .and_then(|meta| meta.map_get("artifacts"))
1026 .and_then(Value::as_array)
1027 .ok_or_else(|| Error::msg("Release manifest contains no artifacts"))?;
1028 artifacts
1029 .iter()
1030 .map(|artifact| {
1031 let name = artifact
1032 .map_get("name")
1033 .and_then(Value::as_str)
1034 .ok_or_else(|| Error::msg("manifest artifact is missing name"))?;
1035 if name.contains('/') || name.contains('\\') || name == "." || name == ".." {
1036 return Err(Error::msg("manifest artifact has invalid name"));
1037 }
1038 let rsg = artifact
1039 .map_get("rsg")
1040 .and_then(Value::as_bin)
1041 .ok_or_else(|| Error::msg("manifest artifact is missing RSG"))?;
1042 Ok(ManifestArtifact {
1043 name: name.to_string(),
1044 rsg: rsg.to_vec(),
1045 })
1046 })
1047 .collect()
1048}
1049
1050fn select_manifest_artifacts(
1051 artifacts: &[ManifestArtifact],
1052 requested: &str,
1053) -> Result<Vec<ManifestArtifact>> {
1054 if requested == "all" {
1055 return Ok(artifacts.to_vec());
1056 }
1057 let selected: Vec<_> = artifacts
1058 .iter()
1059 .filter(|artifact| artifact_pattern_matches(requested, &artifact.name))
1060 .cloned()
1061 .collect();
1062 if selected.is_empty() {
1063 return Err(Error::msg("No available artifacts specified for fetch"));
1064 }
1065 Ok(selected)
1066}
1067
1068fn artifact_pattern_matches(pattern: &str, name: &str) -> bool {
1069 let pattern: Vec<char> = pattern.chars().collect();
1070 let name: Vec<char> = name.chars().collect();
1071 wildcard_match(&pattern, 0, &name, 0)
1072}
1073
1074fn wildcard_match(pattern: &[char], pi: usize, name: &[char], ni: usize) -> bool {
1075 if pi == pattern.len() {
1076 return ni == name.len();
1077 }
1078 match pattern[pi] {
1079 '*' => {
1080 let mut next = pi + 1;
1081 while next < pattern.len() && pattern[next] == '*' {
1082 next += 1;
1083 }
1084 (ni..=name.len()).any(|candidate| wildcard_match(pattern, next, name, candidate))
1085 }
1086 '?' => ni < name.len() && wildcard_match(pattern, pi + 1, name, ni + 1),
1087 '[' => {
1088 let Some((matches_class, next)) = match_char_class(pattern, pi, name.get(ni).copied())
1089 else {
1090 return ni < name.len()
1091 && name[ni] == '['
1092 && wildcard_match(pattern, pi + 1, name, ni + 1);
1093 };
1094 matches_class && wildcard_match(pattern, next, name, ni + 1)
1095 }
1096 literal => {
1097 ni < name.len() && name[ni] == literal && wildcard_match(pattern, pi + 1, name, ni + 1)
1098 }
1099 }
1100}
1101
1102fn match_char_class(pattern: &[char], start: usize, value: Option<char>) -> Option<(bool, usize)> {
1103 let value = value?;
1104 let mut end = start + 1;
1105 while end < pattern.len() && pattern[end] != ']' {
1106 end += 1;
1107 }
1108 if end == pattern.len() || end == start + 1 {
1109 return None;
1110 }
1111
1112 let mut i = start + 1;
1113 let negated = matches!(pattern.get(i), Some('!' | '^'));
1114 if negated {
1115 i += 1;
1116 }
1117 if i >= end {
1118 return None;
1119 }
1120
1121 let mut matched = false;
1122 while i < end {
1123 if i + 2 < end && pattern[i + 1] == '-' {
1124 let lo = pattern[i];
1125 let hi = pattern[i + 2];
1126 if lo <= value && value <= hi {
1127 matched = true;
1128 }
1129 i += 3;
1130 } else {
1131 if pattern[i] == value {
1132 matched = true;
1133 }
1134 i += 1;
1135 }
1136 }
1137
1138 Some((if negated { !matched } else { matched }, end + 1))
1139}
1140
1141fn create_rsg(
1142 identity: &Identity,
1143 message: &[u8],
1144 embed_message: bool,
1145 extra_meta: Vec<(String, Value)>,
1146) -> Result<Vec<u8>> {
1147 const SIG_LEN: usize = 64;
1148 let public_key = identity
1149 .get_public_key()
1150 .ok_or_else(|| Error::msg("signer identity has no public key"))?;
1151 let mut meta = vec![
1152 (
1153 Value::Str("signer".into()),
1154 Value::Bin(identity.hash().to_vec()),
1155 ),
1156 (Value::Str("pubkey".into()), Value::Bin(public_key.to_vec())),
1157 ];
1158 for (key, value) in extra_meta {
1159 if !meta
1160 .iter()
1161 .any(|(existing, _)| matches!(existing, Value::Str(existing) if existing == &key))
1162 {
1163 meta.push((Value::Str(key), value));
1164 }
1165 }
1166
1167 let mut envelope = vec![
1168 (Value::Str("hashtype".into()), Value::Str("sha256".into())),
1169 (
1170 Value::Str("hash".into()),
1171 Value::Bin(sha256(message).to_vec()),
1172 ),
1173 (Value::Str("meta".into()), Value::Map(meta)),
1174 ];
1175 if embed_message {
1176 envelope.push((Value::Str("message".into()), Value::Bin(message.to_vec())));
1177 }
1178 let envelope = msgpack::pack(&Value::Map(envelope));
1179 let signature = identity
1180 .sign(&envelope)
1181 .map_err(|_| Error::msg("signer identity has no private key"))?;
1182 let mut rsg = Vec::with_capacity(SIG_LEN + envelope.len());
1183 rsg.extend_from_slice(&signature);
1184 rsg.extend_from_slice(&envelope);
1185 Ok(rsg)
1186}
1187
1188fn current_unix_timestamp() -> Result<u64> {
1189 Ok(SystemTime::now()
1190 .duration_since(UNIX_EPOCH)
1191 .map_err(|e| Error::msg(format!("system clock is before UNIX epoch: {e}")))?
1192 .as_secs())
1193}
1194
1195fn unix_timestamp_iso(timestamp: u64) -> String {
1196 let days = (timestamp / 86_400) as i64;
1197 let seconds = timestamp % 86_400;
1198 let (year, month, day) = civil_from_days(days);
1199 let hour = seconds / 3_600;
1200 let minute = (seconds % 3_600) / 60;
1201 let second = seconds % 60;
1202 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
1203}
1204
1205fn civil_from_days(days_since_epoch: i64) -> (i64, u64, u64) {
1206 let z = days_since_epoch + 719_468;
1207 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1208 let doe = z - era * 146_097;
1209 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
1210 let y = yoe + era * 400;
1211 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1212 let mp = (5 * doy + 2) / 153;
1213 let day = doy - (153 * mp + 2) / 5 + 1;
1214 let month = mp + if mp < 10 { 3 } else { -9 };
1215 let year = y + if month <= 2 { 1 } else { 0 };
1216 (year, month as u64, day as u64)
1217}
1218
1219fn request(operation: &str, fields: &[(&str, Value)]) -> Vec<u8> {
1220 let mut map = vec![
1221 (
1222 Value::UInt(protocol::IDX_REPOSITORY),
1223 Value::Str("__repository__".into()),
1224 ),
1225 (
1226 Value::Str("operation".into()),
1227 Value::Str(operation.to_string()),
1228 ),
1229 ];
1230 map.extend(
1231 fields
1232 .iter()
1233 .map(|(key, value)| (Value::Str((*key).into()), value.clone())),
1234 );
1235 msgpack::pack(&Value::Map(map))
1236}
1237
1238fn request_with_repository(data: Vec<u8>, repository: &str) -> Result<Vec<u8>> {
1239 let mut value =
1240 msgpack::unpack_exact(&data).map_err(|e| Error::msg(format!("invalid request: {e}")))?;
1241 if let Value::Map(entries) = &mut value {
1242 for (key, val) in entries {
1243 if matches!(key, Value::UInt(v) if *v == protocol::IDX_REPOSITORY) {
1244 *val = Value::Str(repository.to_string());
1245 return Ok(msgpack::pack(&value));
1246 }
1247 }
1248 }
1249 Err(Error::msg("release request missing repository field"))
1250}
1251
1252fn usage() -> &'static str {
1253 "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]"
1254}
1255
1256struct Notes {
1257 content: String,
1258 format: String,
1259}
1260
1261fn load_notes(artifacts_dir: &Path, explicit: Option<&Path>) -> Result<Notes> {
1262 let path = explicit
1263 .map(Path::to_path_buf)
1264 .or_else(|| {
1265 let micron = artifacts_dir.join("RELEASE.mu");
1266 micron.exists().then_some(micron)
1267 })
1268 .or_else(|| {
1269 let markdown = artifacts_dir.join("RELEASE.md");
1270 markdown.exists().then_some(markdown)
1271 });
1272 let Some(path) = path else {
1273 return Ok(Notes {
1274 content: String::new(),
1275 format: "markdown".into(),
1276 });
1277 };
1278 let format = if path.extension().and_then(|ext| ext.to_str()) == Some("mu") {
1279 "micron"
1280 } else {
1281 "markdown"
1282 };
1283 Ok(Notes {
1284 content: fs::read_to_string(path)?,
1285 format: format.into(),
1286 })
1287}
1288
1289struct ArtifactFile {
1290 name: String,
1291 path: PathBuf,
1292}
1293
1294fn artifact_files(dir: &Path) -> Result<Vec<ArtifactFile>> {
1295 let mut out = Vec::new();
1296 for entry in fs::read_dir(dir)? {
1297 let entry = entry?;
1298 if !entry.file_type()?.is_file() {
1299 continue;
1300 }
1301 let name = entry.file_name().to_string_lossy().into_owned();
1302 if matches!(name.as_str(), "RELEASE.md" | "RELEASE.mu") {
1303 continue;
1304 }
1305 out.push(ArtifactFile {
1306 name,
1307 path: entry.path(),
1308 });
1309 }
1310 out.sort_by(|a, b| a.name.cmp(&b.name));
1311 Ok(out)
1312}
1313
1314fn print_release_list(value: &Value, mut output: impl Write) -> Result<()> {
1315 let (releases, latest) = match value {
1316 Value::Array(releases) => (releases.as_slice(), None),
1317 Value::Map(_) => {
1318 let releases = value
1319 .map_get("releases")
1320 .and_then(Value::as_array)
1321 .ok_or_else(|| Error::msg("release list response is missing releases"))?;
1322 let latest = value.map_get("latest").and_then(Value::as_str);
1323 (releases, latest)
1324 }
1325 _ => return Err(Error::msg("release list response is not an array or map")),
1326 };
1327 if releases.is_empty() {
1328 writeln!(output, "No releases")?;
1329 return Ok(());
1330 }
1331 for release in releases {
1332 let tag = release.map_get("tag").and_then(Value::as_str).unwrap_or("");
1333 let status = release
1334 .map_get("status")
1335 .and_then(Value::as_str)
1336 .unwrap_or("unknown");
1337 let artifacts = release
1338 .map_get("artifacts")
1339 .and_then(Value::as_integer)
1340 .unwrap_or(0);
1341 let preview = release
1342 .map_get("preview")
1343 .and_then(Value::as_str)
1344 .unwrap_or("");
1345 let preview = preview.lines().next().unwrap_or("");
1346 writeln!(
1347 output,
1348 "{tag:<16} {status:<10} {artifacts:>3} artifact(s) {preview}"
1349 )?;
1350 }
1351 if let Some(latest) = latest.filter(|latest| !latest.is_empty()) {
1352 writeln!(output, "\nThe latest release is: {latest}")?;
1353 }
1354 Ok(())
1355}
1356
1357fn print_release_view(value: &Value, mut output: impl Write) -> Result<()> {
1358 let tag = value.map_get("tag").and_then(Value::as_str).unwrap_or("");
1359 let status = value
1360 .map_get("status")
1361 .and_then(Value::as_str)
1362 .unwrap_or("unknown");
1363 let thanks = value
1364 .map_get("thanks")
1365 .and_then(Value::as_integer)
1366 .unwrap_or(0);
1367 writeln!(output, "Release : {tag}")?;
1368 writeln!(output, "Status : {status}")?;
1369 writeln!(output, "Thanks : {thanks}")?;
1370 if let Some(notes) = value.map_get("notes").and_then(Value::as_str) {
1371 if !notes.is_empty() {
1372 writeln!(output, "\n{notes}")?;
1373 }
1374 }
1375 if let Some(artifacts) = value.map_get("artifacts").and_then(Value::as_array) {
1376 writeln!(output, "\nArtifacts:")?;
1377 for artifact in artifacts {
1378 let name = artifact
1379 .map_get("name")
1380 .and_then(Value::as_str)
1381 .unwrap_or("");
1382 let size = artifact
1383 .map_get("size")
1384 .and_then(Value::as_integer)
1385 .unwrap_or(0);
1386 writeln!(output, "- {name} ({size} bytes)")?;
1387 }
1388 }
1389 Ok(())
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use super::*;
1395
1396 #[derive(Default)]
1397 struct FakeTransport {
1398 responses: Vec<Vec<u8>>,
1399 resource_responses: Vec<Vec<u8>>,
1400 requests: Vec<Value>,
1401 timeouts: Vec<Duration>,
1402 resource_timeouts: Vec<Duration>,
1403 }
1404
1405 impl ReleaseTransport for FakeTransport {
1406 fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
1407 self.request_with_timeout(data, RELEASE_TIMEOUT_MEDIUM)
1408 }
1409
1410 fn request_with_timeout(&mut self, data: Vec<u8>, timeout: Duration) -> Result<Vec<u8>> {
1411 self.requests.push(msgpack::unpack_exact(&data).unwrap());
1412 self.timeouts.push(timeout);
1413 Ok(self.responses.remove(0))
1414 }
1415
1416 fn request_resource_with_timeout(
1417 &mut self,
1418 data: Vec<u8>,
1419 timeout: Duration,
1420 ) -> Result<Vec<u8>> {
1421 self.requests.push(msgpack::unpack_exact(&data).unwrap());
1422 self.resource_timeouts.push(timeout);
1423 Ok(self.resource_responses.remove(0))
1424 }
1425 }
1426
1427 #[test]
1428 fn parses_release_commands() {
1429 let list = ReleaseOptions::parse([
1430 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1431 "list".into(),
1432 ])
1433 .unwrap();
1434 assert!(matches!(list.command, ReleaseCommand::List));
1435
1436 let create = ReleaseOptions::parse([
1437 "--notes".into(),
1438 "NOTES.md".into(),
1439 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1440 "create".into(),
1441 "v1:dist".into(),
1442 ])
1443 .unwrap();
1444 assert_eq!(
1445 create.command,
1446 ReleaseCommand::Create {
1447 tag: "v1".into(),
1448 artifacts_dir: PathBuf::from("dist"),
1449 notes_path: Some(PathBuf::from("NOTES.md")),
1450 signer_path: None,
1451 package_name: None,
1452 local: false,
1453 }
1454 );
1455
1456 let signed_create = ReleaseOptions::parse([
1457 "--signer".into(),
1458 "signer.rid".into(),
1459 "--name".into(),
1460 "pkg".into(),
1461 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1462 "create".into(),
1463 "v2".into(),
1464 "dist".into(),
1465 ])
1466 .unwrap();
1467 assert_eq!(
1468 signed_create.command,
1469 ReleaseCommand::Create {
1470 tag: "v2".into(),
1471 artifacts_dir: PathBuf::from("dist"),
1472 notes_path: None,
1473 signer_path: Some(PathBuf::from("signer.rid")),
1474 package_name: Some("pkg".into()),
1475 local: false,
1476 }
1477 );
1478
1479 let local_create = ReleaseOptions::parse([
1480 "--local".into(),
1481 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1482 "create".into(),
1483 "v3:dist".into(),
1484 ])
1485 .unwrap();
1486 assert!(matches!(
1487 local_create.command,
1488 ReleaseCommand::Create { local: true, .. }
1489 ));
1490
1491 let fetch = ReleaseOptions::parse([
1492 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1493 "fetch".into(),
1494 "v1:app.bin".into(),
1495 ])
1496 .unwrap();
1497 assert_eq!(
1498 fetch.command,
1499 ReleaseCommand::Fetch {
1500 target: "v1:app.bin".into(),
1501 required_signer: None,
1502 offline: false,
1503 }
1504 );
1505
1506 let offline_fetch =
1507 ReleaseOptions::parse(["--offline".into(), "pkg_v1.rsm".into(), "fetch".into()])
1508 .unwrap();
1509 assert_eq!(
1510 offline_fetch.command,
1511 ReleaseCommand::Fetch {
1512 target: "latest:all".into(),
1513 required_signer: None,
1514 offline: true,
1515 }
1516 );
1517
1518 let verify = ReleaseOptions::parse([
1519 "--signer".into(),
1520 "00112233445566778899aabbccddeeff".into(),
1521 "pkg_v1.rsm".into(),
1522 "verify".into(),
1523 "v1:app.bin".into(),
1524 ])
1525 .unwrap();
1526 assert_eq!(
1527 verify.command,
1528 ReleaseCommand::Fetch {
1529 target: "v1:app.bin".into(),
1530 required_signer: Some("00112233445566778899aabbccddeeff".into()),
1531 offline: true,
1532 }
1533 );
1534
1535 let delete = ReleaseOptions::parse([
1536 "-y".into(),
1537 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1538 "delete".into(),
1539 "v1".into(),
1540 ])
1541 .unwrap();
1542 assert_eq!(
1543 delete.command,
1544 ReleaseCommand::Delete {
1545 tag: "v1".into(),
1546 yes: true
1547 }
1548 );
1549
1550 let latest = ReleaseOptions::parse([
1551 "--yes".into(),
1552 "rns://00112233445566778899aabbccddeeff/group/repo".into(),
1553 "latest".into(),
1554 "v1".into(),
1555 ])
1556 .unwrap();
1557 assert_eq!(
1558 latest.command,
1559 ReleaseCommand::Latest {
1560 tag: "v1".into(),
1561 yes: true
1562 }
1563 );
1564 }
1565
1566 #[test]
1567 fn create_sends_init_artifacts_and_finalize() {
1568 let tmp = tempfile::tempdir().unwrap();
1569 fs::write(tmp.path().join("RELEASE.md"), "# Notes\n").unwrap();
1570 fs::write(tmp.path().join("b.bin"), b"b").unwrap();
1571 fs::write(tmp.path().join("a.bin"), b"a").unwrap();
1572 let mut fake = FakeTransport {
1573 responses: vec![Vec::new(), Vec::new(), Vec::new(), Vec::new()],
1574 resource_responses: Vec::new(),
1575 requests: Vec::new(),
1576 timeouts: Vec::new(),
1577 resource_timeouts: Vec::new(),
1578 };
1579 let mut out = Vec::new();
1580
1581 create_release(
1582 &mut fake,
1583 CreateReleaseOptions {
1584 tag: "v1",
1585 artifacts_dir: tmp.path(),
1586 notes_path: None,
1587 signer_path: None,
1588 package_name: None,
1589 origin_hash: None,
1590 origin_path: None,
1591 local: false,
1592 },
1593 &mut out,
1594 )
1595 .unwrap();
1596
1597 assert_eq!(fake.requests.len(), 4);
1598 assert_eq!(
1599 fake.timeouts,
1600 vec![
1601 RELEASE_TIMEOUT_SHORT,
1602 RELEASE_TIMEOUT_LONG,
1603 RELEASE_TIMEOUT_LONG,
1604 RELEASE_TIMEOUT_MEDIUM
1605 ]
1606 );
1607 assert_eq!(
1608 fake.requests[0]
1609 .map_get("operation")
1610 .and_then(Value::as_str),
1611 Some("create")
1612 );
1613 assert_eq!(
1614 fake.requests[0].map_get("step").and_then(Value::as_str),
1615 Some("init")
1616 );
1617 assert_eq!(
1618 fake.requests[0].map_get("notes").and_then(Value::as_str),
1619 Some("# Notes\n")
1620 );
1621 assert_eq!(
1622 fake.requests[1]
1623 .map_get("artifact_name")
1624 .and_then(Value::as_str),
1625 Some("a.bin")
1626 );
1627 assert_eq!(
1628 fake.requests[2]
1629 .map_get("artifact_name")
1630 .and_then(Value::as_str),
1631 Some("b.bin")
1632 );
1633 assert_eq!(
1634 fake.requests[3].map_get("step").and_then(Value::as_str),
1635 Some("finalize")
1636 );
1637 let out = String::from_utf8(out).unwrap();
1638 assert!(out.contains("Initializing release v1"));
1639 assert!(out.contains("Uploading a.bin (1/2, 1 bytes)"));
1640 assert!(out.contains("Uploading b.bin (2/2, 1 bytes)"));
1641 assert!(out.contains("Finalizing release v1"));
1642 assert!(out.ends_with("Created release v1 with 2 artifact(s)\n"));
1643 }
1644
1645 #[test]
1646 fn create_with_signer_writes_artifact_signatures_and_manifest() {
1647 let tmp = tempfile::tempdir().unwrap();
1648 let signer_dir = tempfile::tempdir().unwrap();
1649 fs::write(tmp.path().join("RELEASE.md"), "# Notes\n").unwrap();
1650 fs::write(tmp.path().join("app.bin"), b"app").unwrap();
1651 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1652 let signer_path = signer_dir.path().join("signer.rid");
1653 rns_net::storage::save_identity(&signer, &signer_path).unwrap();
1654 let mut fake = FakeTransport {
1655 responses: vec![Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()],
1656 resource_responses: Vec::new(),
1657 requests: Vec::new(),
1658 timeouts: Vec::new(),
1659 resource_timeouts: Vec::new(),
1660 };
1661 let mut out = Vec::new();
1662
1663 create_release(
1664 &mut fake,
1665 CreateReleaseOptions {
1666 tag: "v1",
1667 artifacts_dir: tmp.path(),
1668 notes_path: None,
1669 signer_path: Some(&signer_path),
1670 package_name: Some("pkg"),
1671 origin_hash: Some(&[0x11; 16]),
1672 origin_path: Some("group/repo"),
1673 local: false,
1674 },
1675 &mut out,
1676 )
1677 .unwrap();
1678
1679 assert!(tmp.path().join("app.bin.rsg").exists());
1680 assert!(tmp.path().join("manifest.rsm").exists());
1681 assert_eq!(fake.requests.len(), 5);
1682 let uploaded = fake.requests[1..4]
1683 .iter()
1684 .filter_map(|request| request.map_get("artifact_name").and_then(Value::as_str))
1685 .collect::<Vec<_>>();
1686 assert_eq!(uploaded, vec!["app.bin", "app.bin.rsg", "manifest.rsm"]);
1687
1688 let manifest = rsg_envelope(&tmp.path().join("manifest.rsm"));
1689 let meta = manifest.map_get("meta").unwrap();
1690 assert_eq!(meta.map_get("name").and_then(Value::as_str), Some("pkg"));
1691 assert_eq!(meta.map_get("version").and_then(Value::as_str), Some("v1"));
1692 assert!(meta.map_get("released").and_then(Value::as_str).is_some());
1693 assert_eq!(
1694 meta.map_get("origin").and_then(Value::as_bin),
1695 Some(&[0x11; 16][..])
1696 );
1697 assert_eq!(
1698 meta.map_get("path").and_then(Value::as_str),
1699 Some("group/repo")
1700 );
1701 assert!(meta.map_get("commit").is_some_and(Value::is_nil));
1702 let artifacts = meta.map_get("artifacts").and_then(Value::as_array).unwrap();
1703 assert_eq!(artifacts.len(), 1);
1704 let artifact = &artifacts[0];
1705 assert_eq!(
1706 artifact.map_get("name").and_then(Value::as_str),
1707 Some("app.bin")
1708 );
1709 assert!(artifact.map_get("rsg").and_then(Value::as_bin).is_some());
1710
1711 let artifact_sig = rsg_envelope(&tmp.path().join("app.bin.rsg"));
1712 assert!(artifact_sig
1713 .map_get("meta")
1714 .unwrap()
1715 .map_get("timestamp")
1716 .and_then(Value::as_integer)
1717 .is_some());
1718 }
1719
1720 #[test]
1721 fn signed_release_manifest_records_tag_commit_hash() {
1722 let tmp = tempfile::tempdir().unwrap();
1723 let signer_dir = tempfile::tempdir().unwrap();
1724 let repo = tempfile::tempdir().unwrap();
1725 fs::write(tmp.path().join("app.bin"), b"app").unwrap();
1726 fs::write(repo.path().join("tracked.txt"), b"tracked").unwrap();
1727 run_git(repo.path(), &["init"]);
1728 run_git(repo.path(), &["add", "tracked.txt"]);
1729 run_git(
1730 repo.path(),
1731 &[
1732 "-c",
1733 "user.name=Tester",
1734 "-c",
1735 "user.email=test@example.invalid",
1736 "commit",
1737 "-m",
1738 "initial",
1739 ],
1740 );
1741 run_git(repo.path(), &["tag", "v1"]);
1742 let commit = run_git(repo.path(), &["rev-parse", "HEAD"]);
1743
1744 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1745 let signer_path = signer_dir.path().join("signer.rid");
1746 rns_net::storage::save_identity(&signer, &signer_path).unwrap();
1747 sign_release_artifacts_in_repo(
1748 tmp.path(),
1749 &[ArtifactFile {
1750 name: "app.bin".into(),
1751 path: tmp.path().join("app.bin"),
1752 }],
1753 "v1",
1754 "# Notes\n",
1755 &signer_path,
1756 Some("pkg"),
1757 Some(&[0x11; 16]),
1758 Some("group/repo"),
1759 repo.path(),
1760 )
1761 .unwrap();
1762
1763 let manifest = rsg_envelope(&tmp.path().join("manifest.rsm"));
1764 let meta = manifest.map_get("meta").unwrap();
1765 assert_eq!(
1766 meta.map_get("commit").and_then(Value::as_str),
1767 Some(commit.trim())
1768 );
1769 }
1770
1771 #[test]
1772 fn local_create_signs_release_without_uploading() {
1773 let tmp = tempfile::tempdir().unwrap();
1774 let signer_dir = tempfile::tempdir().unwrap();
1775 fs::write(tmp.path().join("RELEASE.md"), "# Notes\n").unwrap();
1776 fs::write(tmp.path().join("app.bin"), b"app").unwrap();
1777 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1778 let signer_path = signer_dir.path().join("signer.rid");
1779 rns_net::storage::save_identity(&signer, &signer_path).unwrap();
1780 let mut fake = FakeTransport::default();
1781 let mut out = Vec::new();
1782
1783 create_release(
1784 &mut fake,
1785 CreateReleaseOptions {
1786 tag: "v1",
1787 artifacts_dir: tmp.path(),
1788 notes_path: None,
1789 signer_path: Some(&signer_path),
1790 package_name: Some("pkg"),
1791 origin_hash: Some(&[0x22; 16]),
1792 origin_path: Some("group/repo"),
1793 local: true,
1794 },
1795 &mut out,
1796 )
1797 .unwrap();
1798
1799 assert!(tmp.path().join("app.bin.rsg").exists());
1800 assert!(tmp.path().join("manifest.rsm").exists());
1801 assert!(fake.requests.is_empty());
1802 let manifest = rsg_envelope(&tmp.path().join("manifest.rsm"));
1803 let meta = manifest.map_get("meta").unwrap();
1804 assert_eq!(
1805 meta.map_get("origin").and_then(Value::as_bin),
1806 Some(&[0x22; 16][..])
1807 );
1808 assert_eq!(
1809 meta.map_get("path").and_then(Value::as_str),
1810 Some("group/repo")
1811 );
1812 let out = String::from_utf8(out).unwrap();
1813 assert!(out.contains("Local release pkg:v1 generated successfully"));
1814 }
1815
1816 fn rsg_envelope(path: &Path) -> Value {
1817 let data = fs::read(path).unwrap();
1818 msgpack::unpack_exact(&data[64..]).unwrap()
1819 }
1820
1821 #[test]
1822 fn missing_requested_artifact_returns_clear_error() {
1823 let artifacts = vec![ManifestArtifact {
1824 name: "app.tar.gz".into(),
1825 rsg: Vec::new(),
1826 }];
1827
1828 let err = select_manifest_artifacts(&artifacts, "missing.whl").unwrap_err();
1829
1830 assert_eq!(
1831 err.to_string(),
1832 "No available artifacts specified for fetch"
1833 );
1834 }
1835
1836 #[test]
1837 fn wildcard_artifact_selection_matches_shell_style_patterns() {
1838 let artifacts = vec![
1839 ManifestArtifact {
1840 name: "app-1.2.3-linux-x86_64.tar.gz".into(),
1841 rsg: Vec::new(),
1842 },
1843 ManifestArtifact {
1844 name: "app-1.2.4-linux-aarch64.tar.gz".into(),
1845 rsg: Vec::new(),
1846 },
1847 ManifestArtifact {
1848 name: "app-1.2.3-py3-none-any.whl".into(),
1849 rsg: Vec::new(),
1850 },
1851 ManifestArtifact {
1852 name: "source_1.2.3.tgz".into(),
1853 rsg: Vec::new(),
1854 },
1855 ];
1856
1857 let wheel = select_manifest_artifacts(&artifacts, "*-py3-*.whl").unwrap();
1858 assert_eq!(
1859 wheel
1860 .iter()
1861 .map(|artifact| artifact.name.as_str())
1862 .collect::<Vec<_>>(),
1863 vec!["app-1.2.3-py3-none-any.whl"]
1864 );
1865
1866 let patch = select_manifest_artifacts(&artifacts, "app-1.2.?-linux-*.tar.gz").unwrap();
1867 assert_eq!(patch.len(), 2);
1868
1869 let class = select_manifest_artifacts(&artifacts, "app-1.2.[34]-linux-*.tar.gz").unwrap();
1870 assert_eq!(class.len(), 2);
1871
1872 let negated = select_manifest_artifacts(&artifacts, "app-1.2.[!4]-linux-*.tar.gz").unwrap();
1873 assert_eq!(negated[0].name, "app-1.2.3-linux-x86_64.tar.gz");
1874
1875 let source = select_manifest_artifacts(&artifacts, "source_*.tgz").unwrap();
1876 assert_eq!(source[0].name, "source_1.2.3.tgz");
1877 }
1878
1879 #[test]
1880 fn fetch_validates_release_target_before_requesting() {
1881 let mut fake = FakeTransport::default();
1882 let err = run_release_command(
1883 &mut fake,
1884 &ReleaseCommand::Fetch {
1885 target: "v1".into(),
1886 required_signer: None,
1887 offline: false,
1888 },
1889 Vec::new(),
1890 )
1891 .unwrap_err();
1892 assert!(err.to_string().contains("Invalid release specification"));
1893 assert!(fake.requests.is_empty());
1894 }
1895
1896 #[test]
1897 fn fetch_validates_manifest_and_downloads_artifact() {
1898 let tmp = tempfile::tempdir().unwrap();
1899 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1900 let artifact_rsg = create_rsg(&signer, b"app", false, Vec::new()).unwrap();
1901 let manifest = create_rsg(
1902 &signer,
1903 b"notes",
1904 true,
1905 vec![
1906 ("name".into(), Value::Str("pkg".into())),
1907 ("version".into(), Value::Str("v1".into())),
1908 ("origin".into(), Value::Bin(vec![0x33; 16])),
1909 ("path".into(), Value::Str("group/repo".into())),
1910 (
1911 "artifacts".into(),
1912 Value::Array(vec![Value::Map(vec![
1913 (Value::Str("name".into()), Value::Str("app.bin".into())),
1914 (Value::Str("rsg".into()), Value::Bin(artifact_rsg)),
1915 ])]),
1916 ),
1917 ],
1918 )
1919 .unwrap();
1920 let required = crate::util::hex(signer.hash());
1921 let mut fake = FakeTransport {
1922 responses: Vec::new(),
1923 resource_responses: vec![manifest, b"app".to_vec()],
1924 requests: Vec::new(),
1925 timeouts: Vec::new(),
1926 resource_timeouts: Vec::new(),
1927 };
1928 let mut out = Vec::new();
1929
1930 fetch_release_into(
1931 &mut fake,
1932 "v1:app.bin",
1933 Some(&required),
1934 tmp.path(),
1935 &mut out,
1936 )
1937 .unwrap();
1938
1939 assert_eq!(fs::read(tmp.path().join("app.bin")).unwrap(), b"app");
1940 assert!(tmp.path().join("pkg_v1.rsm").exists());
1941 assert_eq!(fake.requests.len(), 2);
1942 assert_eq!(
1943 fake.resource_timeouts,
1944 vec![RELEASE_TIMEOUT_LONG, RELEASE_TIMEOUT_LONG]
1945 );
1946 assert_eq!(
1947 fake.requests[0].map_get("artifact").and_then(Value::as_str),
1948 Some("manifest.rsm")
1949 );
1950 assert_eq!(
1951 fake.requests[1].map_get("artifact").and_then(Value::as_str),
1952 Some("app.bin")
1953 );
1954 let out = String::from_utf8(out).unwrap();
1955 assert!(out.contains("Release manifest validated"));
1956 assert!(out.contains("Fetched app.bin"));
1957 }
1958
1959 #[test]
1960 fn offline_manifest_verification_checks_local_artifacts() {
1961 let tmp = tempfile::tempdir().unwrap();
1962 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
1963 fs::write(tmp.path().join("app.bin"), b"app").unwrap();
1964 let artifact_rsg = create_rsg(&signer, b"app", false, Vec::new()).unwrap();
1965 let manifest = create_rsg(
1966 &signer,
1967 b"notes",
1968 true,
1969 vec![
1970 ("name".into(), Value::Str("pkg".into())),
1971 ("version".into(), Value::Str("v1".into())),
1972 ("origin".into(), Value::Bin(vec![0x55; 16])),
1973 ("path".into(), Value::Str("group/repo".into())),
1974 (
1975 "artifacts".into(),
1976 Value::Array(vec![Value::Map(vec![
1977 (Value::Str("name".into()), Value::Str("app.bin".into())),
1978 (Value::Str("rsg".into()), Value::Bin(artifact_rsg)),
1979 ])]),
1980 ),
1981 ],
1982 )
1983 .unwrap();
1984 let manifest_path = tmp.path().join("pkg_v1.rsm");
1985 fs::write(&manifest_path, manifest).unwrap();
1986 let required = crate::util::hex(signer.hash());
1987 let mut out = Vec::new();
1988
1989 verify_release_manifest_offline(&manifest_path, "latest:all", Some(&required), &mut out)
1990 .unwrap();
1991
1992 let out = String::from_utf8(out).unwrap();
1993 assert!(out.contains("Release manifest validated"));
1994 assert!(out.contains("File app.bin validated against manifest"));
1995 assert!(out.contains("All files validated"));
1996 }
1997
1998 #[test]
1999 fn local_manifest_path_resolves_remote_from_signed_metadata() {
2000 let tmp = tempfile::tempdir().unwrap();
2001 let signer = rns_crypto::identity::Identity::new(&mut rns_crypto::OsRng);
2002 let manifest = create_rsg(
2003 &signer,
2004 b"notes",
2005 true,
2006 vec![
2007 ("name".into(), Value::Str("pkg".into())),
2008 ("version".into(), Value::Str("v1".into())),
2009 ("origin".into(), Value::Bin(vec![0x44; 16])),
2010 ("path".into(), Value::Str("group/repo".into())),
2011 ("artifacts".into(), Value::Array(Vec::new())),
2012 ],
2013 )
2014 .unwrap();
2015 let manifest_path = tmp.path().join("pkg_v1.rsm");
2016 fs::write(&manifest_path, manifest).unwrap();
2017 let required = crate::util::hex(signer.hash());
2018
2019 let remote = release_remote_from_manifest_file(&manifest_path, Some(&required)).unwrap();
2020
2021 assert_eq!(remote, "rns://44444444444444444444444444444444/group/repo");
2022 }
2023
2024 #[test]
2025 fn list_and_view_format_msgpack_responses() {
2026 let release = Value::Map(vec![
2027 (Value::Str("tag".into()), Value::Str("v1".into())),
2028 (Value::Str("status".into()), Value::Str("published".into())),
2029 (Value::Str("artifacts".into()), Value::UInt(2)),
2030 (
2031 Value::Str("preview".into()),
2032 Value::Str("First\nSecond line should be hidden".into()),
2033 ),
2034 ]);
2035 let list_body = Value::Map(vec![
2036 (
2037 Value::Str("releases".into()),
2038 Value::Array(vec![release.clone()]),
2039 ),
2040 (Value::Str("latest".into()), Value::Str("v1".into())),
2041 ]);
2042 let mut out = Vec::new();
2043 print_release_list(&list_body, &mut out).unwrap();
2044 let list = String::from_utf8(out).unwrap();
2045 assert!(list.contains("v1"));
2046 assert!(list.contains("published"));
2047 assert!(list.contains("First"));
2048 assert!(!list.contains("Second line should be hidden"));
2049 assert!(list.contains("The latest release is: v1"));
2050
2051 let mut out = Vec::new();
2052 print_release_list(&Value::Array(vec![release]), &mut out).unwrap();
2053 let legacy_list = String::from_utf8(out).unwrap();
2054 assert!(legacy_list.contains("v1"));
2055
2056 let view_body = Value::Map(vec![
2057 (Value::Str("tag".into()), Value::Str("v1".into())),
2058 (Value::Str("status".into()), Value::Str("published".into())),
2059 (Value::Str("thanks".into()), Value::UInt(3)),
2060 (Value::Str("notes".into()), Value::Str("Notes".into())),
2061 (
2062 Value::Str("artifacts".into()),
2063 Value::Array(vec![Value::Map(vec![
2064 (Value::Str("name".into()), Value::Str("dist.tar".into())),
2065 (Value::Str("size".into()), Value::UInt(9)),
2066 ])]),
2067 ),
2068 ]);
2069 let mut out = Vec::new();
2070 print_release_view(&view_body, &mut out).unwrap();
2071 let view = String::from_utf8(out).unwrap();
2072 assert!(view.contains("Release : v1"));
2073 assert!(view.contains("Thanks : 3"));
2074 assert!(view.contains("dist.tar (9 bytes)"));
2075 }
2076
2077 #[test]
2078 fn delete_requires_explicit_yes() {
2079 let mut fake = FakeTransport::default();
2080 let err = run_release_command(
2081 &mut fake,
2082 &ReleaseCommand::Delete {
2083 tag: "v1".into(),
2084 yes: false,
2085 },
2086 Vec::new(),
2087 )
2088 .unwrap_err();
2089 assert!(err.to_string().contains("--yes"));
2090 assert!(fake.requests.is_empty());
2091 }
2092
2093 #[test]
2094 fn delete_with_yes_sends_delete_request() {
2095 let mut fake = FakeTransport {
2096 responses: vec![Vec::new()],
2097 resource_responses: Vec::new(),
2098 requests: Vec::new(),
2099 timeouts: Vec::new(),
2100 resource_timeouts: Vec::new(),
2101 };
2102 let mut out = Vec::new();
2103 run_release_command(
2104 &mut fake,
2105 &ReleaseCommand::Delete {
2106 tag: "v1".into(),
2107 yes: true,
2108 },
2109 &mut out,
2110 )
2111 .unwrap();
2112
2113 assert_eq!(fake.requests.len(), 1);
2114 assert_eq!(
2115 fake.requests[0]
2116 .map_get("operation")
2117 .and_then(Value::as_str),
2118 Some("delete")
2119 );
2120 assert_eq!(
2121 fake.requests[0].map_get("tag").and_then(Value::as_str),
2122 Some("v1")
2123 );
2124 assert_eq!(String::from_utf8(out).unwrap(), "Deleted release v1\n");
2125 }
2126
2127 #[test]
2128 fn latest_requires_yes_and_sends_latest_request() {
2129 let mut fake = FakeTransport::default();
2130 let err = run_release_command(
2131 &mut fake,
2132 &ReleaseCommand::Latest {
2133 tag: "v1".into(),
2134 yes: false,
2135 },
2136 Vec::new(),
2137 )
2138 .unwrap_err();
2139 assert!(err.to_string().contains("--yes"));
2140 assert!(fake.requests.is_empty());
2141
2142 let mut fake = FakeTransport {
2143 responses: vec![Vec::new()],
2144 resource_responses: Vec::new(),
2145 requests: Vec::new(),
2146 timeouts: Vec::new(),
2147 resource_timeouts: Vec::new(),
2148 };
2149 let mut out = Vec::new();
2150 run_release_command(
2151 &mut fake,
2152 &ReleaseCommand::Latest {
2153 tag: "v1".into(),
2154 yes: true,
2155 },
2156 &mut out,
2157 )
2158 .unwrap();
2159
2160 assert_eq!(fake.requests.len(), 1);
2161 assert_eq!(
2162 fake.requests[0]
2163 .map_get("operation")
2164 .and_then(Value::as_str),
2165 Some("latest")
2166 );
2167 assert_eq!(
2168 fake.requests[0].map_get("tag").and_then(Value::as_str),
2169 Some("v1")
2170 );
2171 assert_eq!(
2172 String::from_utf8(out).unwrap(),
2173 "Release v1 set as latest\n"
2174 );
2175 }
2176
2177 fn run_git(repo_path: &Path, args: &[&str]) -> String {
2178 let output = Command::new("git")
2179 .args(args)
2180 .current_dir(repo_path)
2181 .output()
2182 .unwrap();
2183 assert!(
2184 output.status.success(),
2185 "git {:?} failed: {}",
2186 args,
2187 String::from_utf8_lossy(&output.stderr)
2188 );
2189 String::from_utf8(output.stdout).unwrap()
2190 }
2191}