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