Skip to main content

rns_git/
release_cli.rs

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