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