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