Skip to main content

rns_git/
release.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use rns_core::msgpack::{self, Value};
7use rns_net::RequestResponse;
8
9use crate::protocol;
10use crate::util::{hex, validate_repo_name};
11use crate::{Error, Result};
12
13#[derive(Debug, Clone)]
14pub struct ReleaseSummary {
15    pub tag: String,
16    pub hash: String,
17    pub created: u64,
18    pub status: String,
19    pub created_by: String,
20    pub preview: String,
21    pub preview_format: String,
22    pub artifacts: u64,
23}
24
25#[derive(Debug, Clone)]
26pub struct ReleaseData {
27    pub tag: String,
28    pub hash: String,
29    pub created: u64,
30    pub status: String,
31    pub created_by: String,
32    pub notes: String,
33    pub notes_format: String,
34    pub artifacts: Vec<Artifact>,
35    pub thanks: u64,
36}
37
38#[derive(Debug, Clone)]
39pub struct Artifact {
40    pub name: String,
41    pub size: u64,
42}
43
44#[derive(Debug, Clone)]
45pub struct ReleaseRequest {
46    pub repository: String,
47    pub operation: String,
48    pub tag: Option<String>,
49    pub step: Option<String>,
50    pub hash: Option<String>,
51    pub notes: Option<String>,
52    pub notes_format: Option<String>,
53    pub artifact_name: Option<String>,
54    pub artifact_data: Option<Vec<u8>>,
55    pub artifact: Option<String>,
56}
57
58pub fn parse_request(data: &[u8]) -> Result<ReleaseRequest> {
59    let value =
60        msgpack::unpack_exact(data).map_err(|e| Error::msg(format!("invalid msgpack: {e}")))?;
61    let map = value
62        .as_map()
63        .ok_or_else(|| Error::msg("request must be a msgpack map"))?;
64    let repository = map_get_repository(map)
65        .ok_or_else(|| Error::msg("request missing repository"))?
66        .to_string();
67    validate_repo_name(&repository)?;
68    let operation = map_get_str(map, "operation")
69        .ok_or_else(|| Error::msg("request missing operation"))?
70        .to_string();
71    Ok(ReleaseRequest {
72        repository,
73        operation,
74        tag: map_get_str(map, "tag").map(ToOwned::to_owned),
75        step: map_get_str(map, "step").map(ToOwned::to_owned),
76        hash: map_get_str(map, "hash").map(ToOwned::to_owned),
77        notes: map_get_str(map, "notes").map(ToOwned::to_owned),
78        notes_format: map_get_str(map, "notes_format").map(ToOwned::to_owned),
79        artifact_name: map_get_str(map, "artifact_name").map(ToOwned::to_owned),
80        artifact_data: map_get(map, "artifact_data").and_then(value_to_bytes),
81        artifact: map_get_str(map, "artifact").map(ToOwned::to_owned),
82    })
83}
84
85pub fn release_sidecar_path(repo: &Path) -> PathBuf {
86    sidecar_path(repo, "releases")
87}
88
89pub fn list_releases(releases_path: &Path) -> Result<Vec<ReleaseSummary>> {
90    let mut releases = Vec::new();
91    if !releases_path.is_dir() {
92        return Ok(releases);
93    }
94    for entry in fs::read_dir(releases_path)? {
95        let entry = entry?;
96        if !entry.file_type()?.is_dir() {
97            continue;
98        }
99        let release_dir = entry.path();
100        let meta_path = release_dir.join("META");
101        if !meta_path.is_file() {
102            continue;
103        }
104        let meta = read_meta(&meta_path)?;
105        let tag = meta_get(&meta, "tag")
106            .unwrap_or_else(|| entry.file_name().to_string_lossy().into_owned());
107        let (preview, preview_format) = notes_preview(&release_dir)?;
108        releases.push(ReleaseSummary {
109            tag,
110            hash: meta_get(&meta, "hash").unwrap_or_default(),
111            created: meta_get(&meta, "created")
112                .and_then(|v| v.parse().ok())
113                .unwrap_or(0),
114            status: meta_get(&meta, "status").unwrap_or_else(|| "unknown".to_string()),
115            created_by: meta_get(&meta, "created_by").unwrap_or_default(),
116            preview,
117            preview_format,
118            artifacts: artifact_count(&release_dir)?,
119        });
120    }
121    releases.sort_by_key(|release| std::cmp::Reverse(release.created));
122    Ok(releases)
123}
124
125pub fn release_data(releases_path: &Path, tag: &str) -> Result<Option<ReleaseData>> {
126    let tag = clean_tag(tag).ok_or_else(|| Error::msg("invalid tag name"))?;
127    let release_dir = releases_path.join(&tag);
128    if !release_dir.is_dir() {
129        return Ok(None);
130    }
131    let meta_path = release_dir.join("META");
132    if !meta_path.is_file() {
133        return Ok(None);
134    }
135    let meta = read_meta(&meta_path)?;
136    let (notes, notes_format) = read_notes(&release_dir)?;
137    Ok(Some(ReleaseData {
138        tag: meta_get(&meta, "tag").unwrap_or(tag),
139        hash: meta_get(&meta, "hash").unwrap_or_default(),
140        created: meta_get(&meta, "created")
141            .and_then(|v| v.parse().ok())
142            .unwrap_or(0),
143        status: meta_get(&meta, "status").unwrap_or_else(|| "unknown".to_string()),
144        created_by: meta_get(&meta, "created_by").unwrap_or_default(),
145        notes,
146        notes_format,
147        artifacts: artifacts(&release_dir)?,
148        thanks: thanks_count(&release_dir)?,
149    }))
150}
151
152pub fn latest_published_tag(releases_path: &Path) -> Result<Option<String>> {
153    if let Some(tag) = configured_latest_tag(releases_path)? {
154        return Ok(Some(tag));
155    }
156    Ok(list_releases(releases_path)?
157        .into_iter()
158        .find(|release| release.status == "published")
159        .map(|release| release.tag))
160}
161
162pub fn configured_latest_tag(releases_path: &Path) -> Result<Option<String>> {
163    let latest_path = releases_path.join("latest");
164    if !latest_path.is_file() {
165        return Ok(None);
166    }
167    let tag = fs::read_to_string(latest_path)?.trim().to_string();
168    if tag.is_empty() {
169        return Ok(None);
170    }
171    let Some(tag) = clean_tag(&tag) else {
172        return Ok(None);
173    };
174    let Some(release) = release_data(releases_path, &tag)? else {
175        return Ok(None);
176    };
177    Ok((release.status == "published").then_some(release.tag))
178}
179
180pub fn create_init(
181    releases_path: &Path,
182    repo_path: &Path,
183    request: &ReleaseRequest,
184    remote: &[u8; 16],
185) -> Result<Vec<u8>> {
186    let Some(tag) = request.tag.as_deref().and_then(clean_tag) else {
187        return Ok(protocol::status_bytes(
188            protocol::RES_INVALID_REQ,
189            b"invalid tag name",
190        ));
191    };
192    if let Err(err) = verify_tag(repo_path, &tag) {
193        return Ok(protocol::status_bytes(
194            protocol::RES_INVALID_REQ,
195            err.to_string(),
196        ));
197    }
198    fs::create_dir_all(releases_path)?;
199    let release_dir = releases_path.join(&tag);
200    if release_dir.exists() {
201        return Ok(protocol::status_bytes(
202            protocol::RES_DISALLOWED,
203            b"release already exists",
204        ));
205    }
206    fs::create_dir_all(release_dir.join("artifacts"))?;
207    let created = unix_time();
208    let mut meta = vec![
209        ("tag".to_string(), tag.clone()),
210        ("created".to_string(), created.to_string()),
211        ("status".to_string(), "draft".to_string()),
212        ("created_by".to_string(), hex(remote)),
213    ];
214    if let Some(hash) = request.hash.as_deref().filter(|v| !v.is_empty()) {
215        meta.push(("hash".to_string(), hash.to_string()));
216    }
217    write_meta(&release_dir.join("META"), &meta)?;
218    if let Some(notes) = request.notes.as_deref().filter(|v| !v.is_empty()) {
219        let notes_file = match request.notes_format.as_deref() {
220            Some("micron") => "RELEASE.mu",
221            Some("text") => "RELEASE.txt",
222            _ => "RELEASE.md",
223        };
224        fs::write(release_dir.join(notes_file), notes)?;
225    }
226    write_thanks(&release_dir, 0)?;
227    Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
228}
229
230pub fn create_artifact(releases_path: &Path, request: &ReleaseRequest) -> Result<Vec<u8>> {
231    let Some(tag) = request.tag.as_deref().and_then(clean_tag) else {
232        return Ok(protocol::status_bytes(
233            protocol::RES_INVALID_REQ,
234            b"invalid tag name",
235        ));
236    };
237    let artifact_name = request
238        .artifact_name
239        .as_deref()
240        .and_then(clean_component)
241        .ok_or_else(|| Error::msg("invalid artifact name"))?;
242    let data = request
243        .artifact_data
244        .as_deref()
245        .ok_or_else(|| Error::msg("missing artifact data"))?;
246    let release_dir = releases_path.join(&tag);
247    if !release_dir.is_dir() {
248        return Ok(protocol::status_bytes(
249            protocol::RES_NOT_FOUND,
250            b"release not found",
251        ));
252    }
253    let meta_path = release_dir.join("META");
254    let meta = read_meta(&meta_path)?;
255    if meta_get(&meta, "status").as_deref() != Some("draft") {
256        return Ok(protocol::status_bytes(
257            protocol::RES_DISALLOWED,
258            b"release was finalized and is not writable",
259        ));
260    }
261    let artifacts_dir = release_dir.join("artifacts");
262    fs::create_dir_all(&artifacts_dir)?;
263    fs::write(artifacts_dir.join(artifact_name), data)?;
264    Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
265}
266
267pub fn create_finalize(releases_path: &Path, request: &ReleaseRequest) -> Result<Vec<u8>> {
268    let Some(tag) = request.tag.as_deref().and_then(clean_tag) else {
269        return Ok(protocol::status_bytes(
270            protocol::RES_INVALID_REQ,
271            b"invalid tag name",
272        ));
273    };
274    let release_dir = releases_path.join(&tag);
275    if !release_dir.is_dir() {
276        return Ok(protocol::status_bytes(
277            protocol::RES_NOT_FOUND,
278            b"release not found",
279        ));
280    }
281    let meta_path = release_dir.join("META");
282    let mut meta = read_meta(&meta_path)?;
283    if meta_get(&meta, "status").as_deref() != Some("draft") {
284        return Ok(protocol::status_bytes(
285            protocol::RES_DISALLOWED,
286            b"release was finalized and is not writable",
287        ));
288    }
289    set_meta(&mut meta, "status", "published");
290    set_meta(&mut meta, "published_at", &unix_time().to_string());
291    write_meta(&meta_path, &meta)?;
292    if let Err(err) = write_latest_marker(releases_path, &tag) {
293        log::error!(
294            "error setting latest release for {:?}: {}",
295            releases_path,
296            err
297        );
298    }
299    Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
300}
301
302pub fn delete_release(releases_path: &Path, request: &ReleaseRequest) -> Result<Vec<u8>> {
303    let Some(tag) = request.tag.as_deref().and_then(clean_tag) else {
304        return Ok(protocol::status_bytes(
305            protocol::RES_INVALID_REQ,
306            b"invalid tag name",
307        ));
308    };
309    let release_dir = releases_path.join(tag);
310    if !release_dir.is_dir() {
311        return Ok(protocol::status_bytes(
312            protocol::RES_NOT_FOUND,
313            b"release not found",
314        ));
315    }
316    fs::remove_dir_all(release_dir)?;
317    Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
318}
319
320pub fn set_latest_release(releases_path: &Path, request: &ReleaseRequest) -> Result<Vec<u8>> {
321    let Some(tag) = request.tag.as_deref().and_then(clean_tag) else {
322        return Ok(protocol::status_bytes(
323            protocol::RES_INVALID_REQ,
324            b"invalid tag name",
325        ));
326    };
327    let release_dir = releases_path.join(&tag);
328    if !release_dir.is_dir() {
329        return Ok(protocol::status_bytes(
330            protocol::RES_NOT_FOUND,
331            b"release not found",
332        ));
333    }
334    fs::create_dir_all(releases_path)?;
335    write_latest_marker(releases_path, &tag)?;
336    Ok(protocol::status_bytes(protocol::RES_OK, b"ok"))
337}
338
339pub fn list_response(releases_path: &Path) -> Result<Vec<u8>> {
340    let releases = Value::Array(
341        list_releases(releases_path)?
342            .into_iter()
343            .map(summary_value)
344            .collect(),
345    );
346    let latest = configured_latest_tag(releases_path)?
347        .map(Value::Str)
348        .unwrap_or(Value::Nil);
349    Ok(protocol::status_bytes(
350        protocol::RES_OK,
351        msgpack::pack(&Value::Map(vec![
352            (Value::Str("releases".into()), releases),
353            (Value::Str("latest".into()), latest),
354        ])),
355    ))
356}
357
358pub fn view_response(releases_path: &Path, tag: &str) -> Result<Vec<u8>> {
359    let resolved_tag;
360    let tag = if tag == "latest" {
361        let Some(tag) = latest_published_tag(releases_path)? else {
362            return Ok(protocol::status_bytes(
363                protocol::RES_NOT_FOUND,
364                b"no latest release found",
365            ));
366        };
367        resolved_tag = tag;
368        resolved_tag.as_str()
369    } else {
370        tag
371    };
372    let release = match release_data(releases_path, tag) {
373        Ok(release) => release,
374        Err(err) if err.to_string() == "invalid tag name" => {
375            return Ok(protocol::status_bytes(
376                protocol::RES_INVALID_REQ,
377                b"invalid tag name",
378            ));
379        }
380        Err(err) => return Err(err),
381    };
382    match release {
383        Some(release) => Ok(protocol::status_bytes(
384            protocol::RES_OK,
385            msgpack::pack(&data_value(release)),
386        )),
387        None => Ok(protocol::status_bytes(
388            protocol::RES_NOT_FOUND,
389            b"release not found",
390        )),
391    }
392}
393
394pub fn fetch_response(releases_path: &Path, request: &ReleaseRequest) -> Result<RequestResponse> {
395    let Some(tag) = request.tag.as_deref() else {
396        return Ok(RequestResponse::Bytes(protocol::status_bytes(
397            protocol::RES_INVALID_REQ,
398            b"invalid tag name",
399        )));
400    };
401    let tag = if tag == "latest" {
402        let Some(tag) = latest_published_tag(releases_path)? else {
403            return Ok(RequestResponse::Bytes(protocol::status_bytes(
404                protocol::RES_NOT_FOUND,
405                b"no latest release found",
406            )));
407        };
408        tag
409    } else if let Some(tag) = clean_tag(tag) {
410        tag
411    } else {
412        return Ok(RequestResponse::Bytes(protocol::status_bytes(
413            protocol::RES_INVALID_REQ,
414            b"invalid tag name",
415        )));
416    };
417    let Some(raw_artifact) = request.artifact.as_deref() else {
418        return Ok(RequestResponse::Bytes(protocol::status_bytes(
419            protocol::RES_INVALID_REQ,
420            b"invalid artifact name",
421        )));
422    };
423    if raw_artifact.contains('/') || raw_artifact.contains('\\') {
424        return Ok(RequestResponse::Bytes(protocol::status_bytes(
425            protocol::RES_INVALID_REQ,
426            b"invalid artifact name",
427        )));
428    }
429    let Some(artifact) = clean_component(raw_artifact) else {
430        return Ok(RequestResponse::Bytes(protocol::status_bytes(
431            protocol::RES_INVALID_REQ,
432            b"invalid artifact name",
433        )));
434    };
435    let Some(release) = release_data(releases_path, &tag)? else {
436        return Ok(RequestResponse::Bytes(protocol::status_bytes(
437            protocol::RES_NOT_FOUND,
438            b"release not found",
439        )));
440    };
441    if release.status != "published" {
442        return Ok(RequestResponse::Bytes(protocol::status_bytes(
443            protocol::RES_NOT_FOUND,
444            b"release not found",
445        )));
446    }
447    let path = releases_path.join(tag).join("artifacts").join(&artifact);
448    if !path.is_file() {
449        return Ok(RequestResponse::Bytes(protocol::status_bytes(
450            protocol::RES_NOT_FOUND,
451            b"artifact not found",
452        )));
453    }
454    let data = fs::read(path)?;
455    Ok(RequestResponse::Resource {
456        data,
457        metadata: Some(release_fetch_metadata(&artifact)),
458        auto_compress: true,
459    })
460}
461
462fn release_fetch_metadata(name: &str) -> Vec<u8> {
463    msgpack::pack(&Value::Map(vec![
464        (
465            Value::UInt(protocol::IDX_RESULT_CODE),
466            Value::UInt(protocol::RES_OK as u64),
467        ),
468        (Value::Str("name".into()), Value::Str(name.to_string())),
469    ]))
470}
471
472pub fn release_thanks(release_dir: &Path, add: bool) -> Result<u64> {
473    let mut count = thanks_count(release_dir)?;
474    if add {
475        count = count.saturating_add(1);
476        write_thanks(release_dir, count)?;
477    }
478    Ok(count)
479}
480
481pub fn artifact_path(releases_path: &Path, tag: &str, artifact: &str) -> Result<Option<PathBuf>> {
482    let tag = if tag == "latest" {
483        let Some(tag) = latest_published_tag(releases_path)? else {
484            return Ok(None);
485        };
486        tag
487    } else {
488        clean_tag(tag).ok_or_else(|| Error::msg("invalid tag name"))?
489    };
490    let artifact = clean_component(artifact).ok_or_else(|| Error::msg("invalid artifact name"))?;
491    let Some(release) = release_data(releases_path, &tag)? else {
492        return Ok(None);
493    };
494    if release.status != "published" {
495        return Ok(None);
496    }
497    let path = releases_path.join(tag).join("artifacts").join(artifact);
498    Ok(path.is_file().then_some(path))
499}
500
501fn summary_value(release: ReleaseSummary) -> Value {
502    Value::Map(vec![
503        (Value::Str("tag".into()), Value::Str(release.tag)),
504        (Value::Str("hash".into()), Value::Str(release.hash)),
505        (Value::Str("created".into()), Value::UInt(release.created)),
506        (Value::Str("status".into()), Value::Str(release.status)),
507        (
508            Value::Str("created_by".into()),
509            Value::Str(release.created_by),
510        ),
511        (Value::Str("preview".into()), Value::Str(release.preview)),
512        (
513            Value::Str("preview_format".into()),
514            Value::Str(release.preview_format),
515        ),
516        (
517            Value::Str("artifacts".into()),
518            Value::UInt(release.artifacts),
519        ),
520    ])
521}
522
523fn data_value(release: ReleaseData) -> Value {
524    Value::Map(vec![
525        (Value::Str("tag".into()), Value::Str(release.tag)),
526        (Value::Str("hash".into()), Value::Str(release.hash)),
527        (Value::Str("created".into()), Value::UInt(release.created)),
528        (Value::Str("status".into()), Value::Str(release.status)),
529        (
530            Value::Str("created_by".into()),
531            Value::Str(release.created_by),
532        ),
533        (Value::Str("notes".into()), Value::Str(release.notes)),
534        (
535            Value::Str("notes_format".into()),
536            Value::Str(release.notes_format),
537        ),
538        (
539            Value::Str("artifacts".into()),
540            Value::Array(release.artifacts.into_iter().map(artifact_value).collect()),
541        ),
542        (Value::Str("thanks".into()), Value::UInt(release.thanks)),
543    ])
544}
545
546fn write_latest_marker(releases_path: &Path, tag: &str) -> Result<()> {
547    let latest_path = releases_path.join("latest");
548    let tmp_path = releases_path.join("latest.tmp");
549    fs::write(&tmp_path, tag)?;
550    fs::rename(tmp_path, latest_path)?;
551    Ok(())
552}
553
554fn artifact_value(artifact: Artifact) -> Value {
555    Value::Map(vec![
556        (Value::Str("name".into()), Value::Str(artifact.name)),
557        (Value::Str("size".into()), Value::UInt(artifact.size)),
558    ])
559}
560
561fn sidecar_path(repo: &Path, extension: &str) -> PathBuf {
562    let mut name = repo.as_os_str().to_os_string();
563    name.push(format!(".{extension}"));
564    PathBuf::from(name)
565}
566
567fn read_meta(path: &Path) -> Result<Vec<(String, String)>> {
568    let input = fs::read_to_string(path)?;
569    let mut out = Vec::new();
570    for raw in input.lines() {
571        let line = raw.trim();
572        if line.is_empty() || line.starts_with('#') {
573            continue;
574        }
575        if let Some((key, value)) = line.split_once('=') {
576            out.push((key.trim().to_string(), value.trim().to_string()));
577        }
578    }
579    Ok(out)
580}
581
582fn write_meta(path: &Path, values: &[(String, String)]) -> Result<()> {
583    let mut out = String::new();
584    for (key, value) in values {
585        out.push_str(key);
586        out.push_str(" = ");
587        out.push_str(value);
588        out.push('\n');
589    }
590    fs::write(path, out)?;
591    Ok(())
592}
593
594fn meta_get(values: &[(String, String)], key: &str) -> Option<String> {
595    values
596        .iter()
597        .find_map(|(k, v)| (k == key).then(|| v.clone()))
598}
599
600fn set_meta(values: &mut Vec<(String, String)>, key: &str, value: &str) {
601    if let Some((_, existing)) = values.iter_mut().find(|(k, _)| k == key) {
602        *existing = value.to_string();
603    } else {
604        values.push((key.to_string(), value.to_string()));
605    }
606}
607
608fn read_notes(release_dir: &Path) -> Result<(String, String)> {
609    for (file, format) in [
610        ("RELEASE.md", "markdown"),
611        ("RELEASE.mu", "micron"),
612        ("RELEASE.txt", "text"),
613    ] {
614        let path = release_dir.join(file);
615        if path.is_file() {
616            return Ok((fs::read_to_string(path)?, format.to_string()));
617        }
618    }
619    Ok((String::new(), "text".to_string()))
620}
621
622fn notes_preview(release_dir: &Path) -> Result<(String, String)> {
623    let (notes, format) = read_notes(release_dir)?;
624    let first = notes
625        .lines()
626        .map(str::trim)
627        .find(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('>'))
628        .unwrap_or("");
629    Ok((first.chars().take(256).collect(), format))
630}
631
632fn artifacts(release_dir: &Path) -> Result<Vec<Artifact>> {
633    let artifacts_dir = release_dir.join("artifacts");
634    let mut out = Vec::new();
635    if !artifacts_dir.is_dir() {
636        return Ok(out);
637    }
638    for entry in fs::read_dir(artifacts_dir)? {
639        let entry = entry?;
640        if !entry.file_type()?.is_file() {
641            continue;
642        }
643        out.push(Artifact {
644            name: entry.file_name().to_string_lossy().into_owned(),
645            size: entry.metadata()?.len(),
646        });
647    }
648    out.sort_by(|a, b| a.name.cmp(&b.name));
649    Ok(out)
650}
651
652fn artifact_count(release_dir: &Path) -> Result<u64> {
653    Ok(artifacts(release_dir)?.len() as u64)
654}
655
656fn thanks_count(release_dir: &Path) -> Result<u64> {
657    let path = release_dir.join("THANKS");
658    if !path.is_file() {
659        return Ok(0);
660    }
661    let bytes = fs::read(path)?;
662    if let Ok(value) = msgpack::unpack_exact(&bytes) {
663        return Ok(value
664            .map_get("count")
665            .and_then(Value::as_integer)
666            .unwrap_or(0)
667            .max(0) as u64);
668    }
669    Ok(String::from_utf8_lossy(&bytes).trim().parse().unwrap_or(0))
670}
671
672fn write_thanks(release_dir: &Path, count: u64) -> Result<()> {
673    fs::write(
674        release_dir.join("THANKS"),
675        msgpack::pack(&Value::Map(vec![(
676            Value::Str("count".into()),
677            Value::UInt(count),
678        )])),
679    )?;
680    Ok(())
681}
682
683fn verify_tag(repo_path: &Path, tag: &str) -> Result<()> {
684    let output = Command::new("git")
685        .arg("--git-dir")
686        .arg(repo_path)
687        .arg("rev-parse")
688        .arg("--verify")
689        .arg(format!("refs/tags/{tag}"))
690        .output()?;
691    if output.status.success() {
692        Ok(())
693    } else {
694        Err(Error::msg(format!(
695            "tag '{tag}' does not exist in repository"
696        )))
697    }
698}
699
700fn unix_time() -> u64 {
701    SystemTime::now()
702        .duration_since(UNIX_EPOCH)
703        .unwrap_or_default()
704        .as_secs()
705}
706
707fn clean_component(value: &str) -> Option<String> {
708    let name = Path::new(value).file_name()?.to_str()?.trim();
709    (!name.is_empty() && name != "." && name != "..").then(|| name.to_string())
710}
711
712fn clean_tag(value: &str) -> Option<String> {
713    if value.contains('/') || value.contains('\\') {
714        return None;
715    }
716    clean_component(value)
717}
718
719fn map_get_repository(map: &[(Value, Value)]) -> Option<&str> {
720    map.iter().find_map(|(key, value)| match key {
721        Value::UInt(v) if *v == protocol::IDX_REPOSITORY => value.as_str(),
722        Value::Str(v) if v == "repository" => value.as_str(),
723        _ => None,
724    })
725}
726
727fn map_get<'a>(map: &'a [(Value, Value)], key: &str) -> Option<&'a Value> {
728    map.iter().find_map(|(k, v)| match k {
729        Value::Str(s) if s == key => Some(v),
730        _ => None,
731    })
732}
733
734fn map_get_str<'a>(map: &'a [(Value, Value)], key: &str) -> Option<&'a str> {
735    map_get(map, key).and_then(Value::as_str)
736}
737
738fn value_to_bytes(value: &Value) -> Option<Vec<u8>> {
739    match value {
740        Value::Bin(bytes) => Some(bytes.clone()),
741        Value::Str(text) => Some(text.as_bytes().to_vec()),
742        _ => None,
743    }
744}