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            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            &notes.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}