mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-10-18 19:30:27 +08:00
Add release COB ports and commands
- Add release types and the Releases and ReleasesMut ports - Create, delete and redact releases - Register, attest and locate artifacts - Filter releases with radicle-artifact - Add release commands and mirror them in test API Co-authored-by: Brandon Oxendine <brandon.oxendine@gmail.com> Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
committed by
Rūdolfs Ošiņš
co-authored by
Brandon Oxendine
Rūdolfs Ošiņš
parent
2ffcf1b95a
commit
7a0b67da64
Generated
+637
-55
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ use crate::AppState;
|
|||||||
pub mod issue;
|
pub mod issue;
|
||||||
pub mod job;
|
pub mod job;
|
||||||
pub mod patch;
|
pub mod patch;
|
||||||
|
pub mod release;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_embed(
|
pub async fn get_embed(
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
use radicle::git;
|
||||||
|
use radicle::identity;
|
||||||
|
|
||||||
|
use radicle_types as types;
|
||||||
|
use radicle_types::error::Error;
|
||||||
|
use radicle_types::traits::release::{ReleaseFilter, Releases};
|
||||||
|
use radicle_types::traits::release_mut::ReleasesMut;
|
||||||
|
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::commands::blocking;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn list_releases(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
filter: Option<ReleaseFilter>,
|
||||||
|
skip: Option<usize>,
|
||||||
|
// None: return all releases, `skip` is ignored.
|
||||||
|
take: Option<usize>,
|
||||||
|
) -> Result<types::cobs::PaginatedQuery<Vec<types::cobs::release::Release>>, Error> {
|
||||||
|
blocking(ctx, move |ctx| ctx.list_releases(rid, filter, skip, take)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn release_by_id(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
id: git::Oid,
|
||||||
|
) -> Result<Option<types::cobs::release::Release>, Error> {
|
||||||
|
blocking(ctx, move |ctx| ctx.release_by_id(rid, id)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn release_counts(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
) -> Result<types::cobs::release::ReleaseCounts, Error> {
|
||||||
|
blocking(ctx, move |ctx| ctx.release_counts(rid)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn compute_artifact_cid(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
) -> Result<types::cobs::release::ArtifactDigest, Error> {
|
||||||
|
blocking(ctx, move |ctx| ctx.compute_artifact_cid(path)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_or_open_release(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
oid: git::Oid,
|
||||||
|
tag: Option<git::Oid>,
|
||||||
|
) -> Result<String, Error> {
|
||||||
|
blocking(ctx, move |ctx| ctx.create_or_open_release(rid, oid, tag)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn register_artifact(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
name: String,
|
||||||
|
size_bytes: u64,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
blocking(ctx, move |ctx| {
|
||||||
|
ctx.register_artifact(rid, release_id, cid, name, size_bytes)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn attest_artifact(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
blocking(ctx, move |ctx| {
|
||||||
|
ctx.attest_artifact(rid, release_id, cid, path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_artifact_metadata(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
key: String,
|
||||||
|
value: serde_json::Value,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
blocking(ctx, move |ctx| {
|
||||||
|
ctx.set_artifact_metadata(rid, release_id, cid, key, value)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remove_artifact_metadata(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
key: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
blocking(ctx, move |ctx| {
|
||||||
|
ctx.remove_artifact_metadata(rid, release_id, cid, key)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_artifact_location(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
blocking(ctx, move |ctx| ctx.add_location(rid, release_id, cid, url)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remove_artifact_location(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
blocking(ctx, move |ctx| {
|
||||||
|
ctx.remove_location(rid, release_id, cid, url)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn redact_artifact(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
reason: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
blocking(ctx, move |ctx| {
|
||||||
|
ctx.redact_artifact(rid, release_id, cid, reason)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick one or more files to attach to a release. Returns an empty list when
|
||||||
|
/// the user cancels. The blocking dialog runs off the main thread, which the
|
||||||
|
/// plugin requires.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pick_artifact_files(app: tauri::AppHandle) -> Result<Vec<std::path::PathBuf>, Error> {
|
||||||
|
let paths = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
app.dialog()
|
||||||
|
.file()
|
||||||
|
.blocking_pick_files()
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(paths
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|path| path.into_path().ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick a single directory to attach as a collection artifact.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn pick_artifact_directory(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) -> Result<Option<std::path::PathBuf>, Error> {
|
||||||
|
let path =
|
||||||
|
tauri::async_runtime::spawn_blocking(move || app.dialog().file().blocking_pick_folder())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(path.and_then(|path| path.into_path().ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_release(
|
||||||
|
ctx: tauri::State<'_, AppState>,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
blocking(ctx, move |ctx| ctx.delete_release(rid, release_id)).await
|
||||||
|
}
|
||||||
@@ -83,6 +83,21 @@ pub fn run() {
|
|||||||
cob::patch::create_patch_review,
|
cob::patch::create_patch_review,
|
||||||
cob::patch::rebuild_patch_cache,
|
cob::patch::rebuild_patch_cache,
|
||||||
cob::patch::revisions_by_patch,
|
cob::patch::revisions_by_patch,
|
||||||
|
cob::release::list_releases,
|
||||||
|
cob::release::release_by_id,
|
||||||
|
cob::release::release_counts,
|
||||||
|
cob::release::compute_artifact_cid,
|
||||||
|
cob::release::create_or_open_release,
|
||||||
|
cob::release::register_artifact,
|
||||||
|
cob::release::attest_artifact,
|
||||||
|
cob::release::set_artifact_metadata,
|
||||||
|
cob::release::remove_artifact_metadata,
|
||||||
|
cob::release::add_artifact_location,
|
||||||
|
cob::release::remove_artifact_location,
|
||||||
|
cob::release::redact_artifact,
|
||||||
|
cob::release::delete_release,
|
||||||
|
cob::release::pick_artifact_files,
|
||||||
|
cob::release::pick_artifact_directory,
|
||||||
cob::save_embed_by_bytes,
|
cob::save_embed_by_bytes,
|
||||||
cob::save_embed_by_path,
|
cob::save_embed_by_path,
|
||||||
cob::save_embed_to_disk,
|
cob::save_embed_to_disk,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ log = { version = "0.4.22" }
|
|||||||
infer = { version = "0.19.0" }
|
infer = { version = "0.19.0" }
|
||||||
mime-infer = { version = "3.0.0" }
|
mime-infer = { version = "3.0.0" }
|
||||||
radicle = { version = "0.25", features = ["tor", "i2p"] }
|
radicle = { version = "0.25", features = ["tor", "i2p"] }
|
||||||
|
radicle-artifact = { version = "0.22" }
|
||||||
|
radicle-artifact-core = { version = "0.22" }
|
||||||
radicle-job = { version = "0.6" }
|
radicle-job = { version = "0.6" }
|
||||||
radicle-localtime = { version = "0.1.0", features = ["serde"] }
|
radicle-localtime = { version = "0.1.0", features = ["serde"] }
|
||||||
radicle-surf = { version = "0.29.0", features = ["serde"] }
|
radicle-surf = { version = "0.29.0", features = ["serde"] }
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Author } from "../Author";
|
||||||
|
import type { Location } from "./Location";
|
||||||
|
import type { Redaction } from "./Redaction";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single file published as part of a release, addressed by its content id.
|
||||||
|
*/
|
||||||
|
export type Artifact = {
|
||||||
|
cid: string;
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Whether the content is a folder rather than a single file.
|
||||||
|
*/
|
||||||
|
directory: boolean;
|
||||||
|
author: Author;
|
||||||
|
/**
|
||||||
|
* Flattened across contributors: one entry per node/url pair.
|
||||||
|
*/
|
||||||
|
locations: Array<Location>;
|
||||||
|
attestations: Array<Author>;
|
||||||
|
redactions: Array<Redaction>;
|
||||||
|
/**
|
||||||
|
* Whether its author or a delegate redacted it, which hides it by default.
|
||||||
|
*/
|
||||||
|
redacted: boolean;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A locally computed content id and size for a file or directory staged for
|
||||||
|
* release, before it is registered on the COB.
|
||||||
|
*/
|
||||||
|
export type ArtifactDigest = {
|
||||||
|
cid: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
/**
|
||||||
|
* Files this covers: 1 for a file, the walked count for a directory. Lets
|
||||||
|
* the app say what a directory is about to publish before it publishes it.
|
||||||
|
*/
|
||||||
|
fileCount: number;
|
||||||
|
/**
|
||||||
|
* Whether the path is a directory, which is registered as a single
|
||||||
|
* artifact named after the folder.
|
||||||
|
*/
|
||||||
|
directory: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Author } from "../Author";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A place an artifact can be fetched from, contributed by a single node.
|
||||||
|
*/
|
||||||
|
export type Location = { user: Author; url: string };
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Author } from "../Author";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node flagging an artifact, with the reason it gave.
|
||||||
|
*/
|
||||||
|
export type Redaction = { user: Author; reason: string };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Author } from "../Author";
|
||||||
|
import type { Artifact } from "./Artifact";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A release, keyed by a commit and optionally an annotated tag. The COB has
|
||||||
|
* no title of its own: `title` and `tagName` are resolved from the tag or
|
||||||
|
* commit message, and are absent when they cannot be read.
|
||||||
|
*/
|
||||||
|
export type Release = {
|
||||||
|
id: string;
|
||||||
|
oid: string;
|
||||||
|
tag?: string;
|
||||||
|
tagName?: string;
|
||||||
|
title?: string;
|
||||||
|
createdAt: number;
|
||||||
|
creator: Author;
|
||||||
|
artifacts: Array<Artifact>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release counts bucketed by creator trust and artifact redaction. A redacted
|
||||||
|
* release has every artifact redacted by a trusted party. A release with no
|
||||||
|
* artifacts is not redacted.
|
||||||
|
*/
|
||||||
|
export type ReleaseCounts = {
|
||||||
|
delegate: number;
|
||||||
|
delegateRedacted: number;
|
||||||
|
other: number;
|
||||||
|
otherRedacted: number;
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { ReleaseScope } from "./ReleaseScope";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which side of the release view the caller asks for.
|
||||||
|
*/
|
||||||
|
export type ReleaseFilter = {
|
||||||
|
/**
|
||||||
|
* Show releases and artifacts by delegates, or by everyone else.
|
||||||
|
*/
|
||||||
|
scope: ReleaseScope;
|
||||||
|
/**
|
||||||
|
* Include artifacts redacted by their author or a delegate.
|
||||||
|
*/
|
||||||
|
showRedacted: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two disjoint author scopes of the release view. No local user is
|
||||||
|
* trusted, so trusted means delegates.
|
||||||
|
*/
|
||||||
|
export type ReleaseScope = "trusted" | "untrusted";
|
||||||
@@ -3,6 +3,12 @@ import type { Tagger } from "./Tagger";
|
|||||||
|
|
||||||
export type Tag = {
|
export type Tag = {
|
||||||
oid: string;
|
oid: string;
|
||||||
|
/**
|
||||||
|
* Oid of the annotated tag object itself, as opposed to the commit it
|
||||||
|
* peels to. Absent for lightweight tags. A release records this so it can
|
||||||
|
* resolve its own title and tag name.
|
||||||
|
*/
|
||||||
|
tagOid?: string;
|
||||||
/**
|
/**
|
||||||
* Tagger time for annotated tags, otherwise the commit time of the
|
* Tagger time for annotated tags, otherwise the commit time of the
|
||||||
* tagged commit. Seconds since epoch.
|
* tagged commit. Seconds since epoch.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use radicle::node::{Alias, AliasStore};
|
|||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
pub mod job;
|
pub mod job;
|
||||||
|
pub mod release;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
pub mod thread;
|
pub mod thread;
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use radicle::cob;
|
||||||
|
use radicle::git;
|
||||||
|
use radicle::identity::Did;
|
||||||
|
use radicle::node::AliasStore;
|
||||||
|
use radicle::storage::git::Repository;
|
||||||
|
use serde::Serialize;
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
use radicle_artifact::display::{CommitTitle, TagName};
|
||||||
|
use radicle_artifact_core::cid::{ArtifactKind, artifact_kind};
|
||||||
|
|
||||||
|
use crate::cobs;
|
||||||
|
|
||||||
|
/// A locally computed content id and size for a file or directory staged for
|
||||||
|
/// release, before it is registered on the COB.
|
||||||
|
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export)]
|
||||||
|
#[ts(export_to = "cob/release/")]
|
||||||
|
pub struct ArtifactDigest {
|
||||||
|
pub cid: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub size_bytes: u64,
|
||||||
|
/// Files this covers: 1 for a file, the walked count for a directory. Lets
|
||||||
|
/// the app say what a directory is about to publish before it publishes it.
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub file_count: u64,
|
||||||
|
/// Whether the path is a directory, which is registered as a single
|
||||||
|
/// artifact named after the folder.
|
||||||
|
pub directory: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A place an artifact can be fetched from, contributed by a single node.
|
||||||
|
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export)]
|
||||||
|
#[ts(export_to = "cob/release/")]
|
||||||
|
pub struct Location {
|
||||||
|
pub user: cobs::Author,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node flagging an artifact, with the reason it gave.
|
||||||
|
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export)]
|
||||||
|
#[ts(export_to = "cob/release/")]
|
||||||
|
pub struct Redaction {
|
||||||
|
pub user: cobs::Author,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single file published as part of a release, addressed by its content id.
|
||||||
|
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export)]
|
||||||
|
#[ts(export_to = "cob/release/")]
|
||||||
|
pub struct Artifact {
|
||||||
|
pub cid: String,
|
||||||
|
pub name: String,
|
||||||
|
/// Whether the content is a folder rather than a single file.
|
||||||
|
pub directory: bool,
|
||||||
|
pub author: cobs::Author,
|
||||||
|
/// Flattened across contributors: one entry per node/url pair.
|
||||||
|
pub locations: Vec<Location>,
|
||||||
|
pub attestations: Vec<cobs::Author>,
|
||||||
|
pub redactions: Vec<Redaction>,
|
||||||
|
/// Whether its author or a delegate redacted it, which hides it by default.
|
||||||
|
pub redacted: bool,
|
||||||
|
#[ts(type = "Record<string, unknown>")]
|
||||||
|
pub metadata: BTreeMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Artifact {
|
||||||
|
pub fn new(
|
||||||
|
cid: &radicle_artifact::Cid,
|
||||||
|
artifact: &radicle_artifact::Artifact,
|
||||||
|
delegates: &BTreeSet<Did>,
|
||||||
|
aliases: &impl AliasStore,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
cid: cid.to_string(),
|
||||||
|
name: artifact.name().to_string(),
|
||||||
|
directory: matches!(artifact_kind(cid), Ok(ArtifactKind::Collection)),
|
||||||
|
author: cobs::Author::new(artifact.author(), aliases),
|
||||||
|
locations: artifact
|
||||||
|
.locations()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(did, urls)| {
|
||||||
|
urls.iter().map(move |url| Location {
|
||||||
|
user: cobs::Author::new(did, aliases),
|
||||||
|
url: url.to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
attestations: artifact
|
||||||
|
.attestations()
|
||||||
|
.iter()
|
||||||
|
.map(|did| cobs::Author::new(did, aliases))
|
||||||
|
.collect(),
|
||||||
|
redactions: artifact
|
||||||
|
.redactions()
|
||||||
|
.iter()
|
||||||
|
.map(|(did, reason)| Redaction {
|
||||||
|
user: cobs::Author::new(did, aliases),
|
||||||
|
reason: reason.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
redacted: artifact.is_redacted_by_trusted(delegates),
|
||||||
|
metadata: artifact.trusted_metadata(delegates),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Release counts bucketed by creator trust and artifact redaction. A redacted
|
||||||
|
/// release has every artifact redacted by a trusted party. A release with no
|
||||||
|
/// artifacts is not redacted.
|
||||||
|
#[derive(Clone, Copy, Serialize, TS, Debug, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export)]
|
||||||
|
#[ts(export_to = "cob/release/")]
|
||||||
|
pub struct ReleaseCounts {
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub delegate: usize,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub delegate_redacted: usize,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub other: usize,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub other_redacted: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<radicle_artifact::ReleaseCounts> for ReleaseCounts {
|
||||||
|
fn from(counts: radicle_artifact::ReleaseCounts) -> Self {
|
||||||
|
Self {
|
||||||
|
delegate: counts.delegate,
|
||||||
|
delegate_redacted: counts.delegate_redacted,
|
||||||
|
other: counts.other,
|
||||||
|
other_redacted: counts.other_redacted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two disjoint author scopes of the release view. No local user is
|
||||||
|
/// trusted, so trusted means delegates.
|
||||||
|
#[derive(Clone, Copy, Default, Serialize, serde::Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export)]
|
||||||
|
#[ts(export_to = "cob/release/")]
|
||||||
|
pub enum ReleaseScope {
|
||||||
|
#[default]
|
||||||
|
Trusted,
|
||||||
|
Untrusted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which side of the release view the caller asks for.
|
||||||
|
#[derive(Clone, Copy, Default, Serialize, serde::Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
#[ts(export)]
|
||||||
|
#[ts(export_to = "cob/release/")]
|
||||||
|
pub struct ReleaseFilter {
|
||||||
|
/// Show releases and artifacts by delegates, or by everyone else.
|
||||||
|
pub scope: ReleaseScope,
|
||||||
|
/// Include artifacts redacted by their author or a delegate.
|
||||||
|
pub show_redacted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release, keyed by a commit and optionally an annotated tag. The COB has
|
||||||
|
/// no title of its own: `title` and `tagName` are resolved from the tag or
|
||||||
|
/// commit message, and are absent when they cannot be read.
|
||||||
|
#[derive(Clone, Serialize, TS, Debug, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export)]
|
||||||
|
#[ts(export_to = "cob/release/")]
|
||||||
|
pub struct Release {
|
||||||
|
#[ts(as = "String")]
|
||||||
|
pub id: git::Oid,
|
||||||
|
#[ts(as = "String")]
|
||||||
|
pub oid: git::Oid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(as = "Option<String>", optional)]
|
||||||
|
pub tag: Option<git::Oid>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub tag_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
pub created_at: cob::Timestamp,
|
||||||
|
pub creator: cobs::Author,
|
||||||
|
pub artifacts: Vec<Artifact>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Release {
|
||||||
|
/// Build a release from its COB, with `artifacts` already narrowed to the
|
||||||
|
/// ones visible under the caller's filter.
|
||||||
|
pub fn new(
|
||||||
|
id: radicle_artifact::ReleaseId,
|
||||||
|
release: &radicle_artifact::Release,
|
||||||
|
repo: &Repository,
|
||||||
|
aliases: &impl AliasStore,
|
||||||
|
artifacts: Vec<Artifact>,
|
||||||
|
) -> Self {
|
||||||
|
let title = release
|
||||||
|
.tag()
|
||||||
|
.and_then(|tag| repo.title(tag))
|
||||||
|
.or_else(|| repo.title(release.oid()));
|
||||||
|
let tag_name = release.tag().and_then(|tag| repo.tag_name(tag));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: id.oid(),
|
||||||
|
oid: *release.oid(),
|
||||||
|
tag: release.tag().copied(),
|
||||||
|
tag_name,
|
||||||
|
title,
|
||||||
|
created_at: release.timestamp(),
|
||||||
|
creator: cobs::Author::new(release.creator(), aliases),
|
||||||
|
artifacts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,6 +154,14 @@ pub enum Error {
|
|||||||
#[error("revision not found: {0}")]
|
#[error("revision not found: {0}")]
|
||||||
RevisionNotFound(String),
|
RevisionNotFound(String),
|
||||||
|
|
||||||
|
/// Commit not found in the repository.
|
||||||
|
#[error("commit not found: {0}")]
|
||||||
|
CommitNotFound(radicle::git::Oid),
|
||||||
|
|
||||||
|
/// A location URL that peers could not fetch an artifact from.
|
||||||
|
#[error("invalid location: {0}")]
|
||||||
|
InvalidLocation(String),
|
||||||
|
|
||||||
/// Surf error.
|
/// Surf error.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Surf(#[from] radicle_surf::Error),
|
Surf(#[from] radicle_surf::Error),
|
||||||
@@ -189,6 +197,30 @@ pub enum Error {
|
|||||||
/// Serde JSON error.
|
/// Serde JSON error.
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
SerdeJSON(#[from] serde_json::error::Error),
|
SerdeJSON(#[from] serde_json::error::Error),
|
||||||
|
|
||||||
|
/// Release creation error.
|
||||||
|
#[error(transparent)]
|
||||||
|
ReleaseCreate(#[from] radicle_artifact::error::Create),
|
||||||
|
|
||||||
|
/// Artifact metadata error.
|
||||||
|
#[error(transparent)]
|
||||||
|
ArtifactMetadata(#[from] radicle_artifact::error::Metadata),
|
||||||
|
|
||||||
|
/// Artifact redaction error.
|
||||||
|
#[error(transparent)]
|
||||||
|
ArtifactRedact(#[from] radicle_artifact::error::Redact),
|
||||||
|
|
||||||
|
/// Artifact content id, key or I/O error.
|
||||||
|
#[error(transparent)]
|
||||||
|
ArtifactCore(#[from] radicle_artifact_core::Error),
|
||||||
|
|
||||||
|
/// Object id parse error.
|
||||||
|
#[error(transparent)]
|
||||||
|
ParseObjectId(#[from] radicle::cob::object::ParseObjectId),
|
||||||
|
|
||||||
|
/// URL parse error.
|
||||||
|
#[error(transparent)]
|
||||||
|
UrlParse(#[from] url::ParseError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Error {
|
impl Error {
|
||||||
@@ -217,6 +249,11 @@ impl Error {
|
|||||||
}
|
}
|
||||||
Error::FileTooLarge(_) => "PayloadError.TooLarge",
|
Error::FileTooLarge(_) => "PayloadError.TooLarge",
|
||||||
Error::ReviewExists => "PatchError.ReviewExists",
|
Error::ReviewExists => "PatchError.ReviewExists",
|
||||||
|
Error::CommitNotFound(_) => "RepoError.CommitNotFound",
|
||||||
|
Error::InvalidLocation(_) => "ArtifactError.InvalidLocation",
|
||||||
|
Error::ArtifactCore(radicle_artifact_core::Error::CidMismatch { .. }) => {
|
||||||
|
"ArtifactError.CidMismatch"
|
||||||
|
}
|
||||||
_ => "UnknownError",
|
_ => "UnknownError",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use traits::inbox::Inbox;
|
|||||||
use traits::issue::{Issues, IssuesMut};
|
use traits::issue::{Issues, IssuesMut};
|
||||||
use traits::job::Jobs;
|
use traits::job::Jobs;
|
||||||
use traits::patch::{Patches, PatchesMut};
|
use traits::patch::{Patches, PatchesMut};
|
||||||
|
use traits::release::Releases;
|
||||||
|
use traits::release_mut::ReleasesMut;
|
||||||
use traits::repo::Repo;
|
use traits::repo::Repo;
|
||||||
use traits::thread::Thread;
|
use traits::thread::Thread;
|
||||||
|
|
||||||
@@ -37,6 +39,8 @@ impl IssuesMut for AppState {}
|
|||||||
impl Jobs for AppState {}
|
impl Jobs for AppState {}
|
||||||
impl Patches for AppState {}
|
impl Patches for AppState {}
|
||||||
impl PatchesMut for AppState {}
|
impl PatchesMut for AppState {}
|
||||||
|
impl Releases for AppState {}
|
||||||
|
impl ReleasesMut for AppState {}
|
||||||
impl Profile for AppState {
|
impl Profile for AppState {
|
||||||
fn profile(&self) -> radicle::Profile {
|
fn profile(&self) -> radicle::Profile {
|
||||||
self.profile.clone()
|
self.profile.clone()
|
||||||
|
|||||||
@@ -217,6 +217,12 @@ pub struct Tagger {
|
|||||||
pub struct Tag {
|
pub struct Tag {
|
||||||
#[ts(as = "String")]
|
#[ts(as = "String")]
|
||||||
pub oid: git::Oid,
|
pub oid: git::Oid,
|
||||||
|
/// Oid of the annotated tag object itself, as opposed to the commit it
|
||||||
|
/// peels to. Absent for lightweight tags. A release records this so it can
|
||||||
|
/// resolve its own title and tag name.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[ts(as = "Option<String>", optional)]
|
||||||
|
pub tag_oid: Option<git::Oid>,
|
||||||
/// Tagger time for annotated tags, otherwise the commit time of the
|
/// Tagger time for annotated tags, otherwise the commit time of the
|
||||||
/// tagged commit. Seconds since epoch.
|
/// tagged commit. Seconds since epoch.
|
||||||
#[ts(type = "number")]
|
#[ts(type = "number")]
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ pub mod inbox;
|
|||||||
pub mod issue;
|
pub mod issue;
|
||||||
pub mod job;
|
pub mod job;
|
||||||
pub mod patch;
|
pub mod patch;
|
||||||
|
pub mod release;
|
||||||
|
pub mod release_mut;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
pub mod thread;
|
pub mod thread;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use radicle::identity::Did;
|
||||||
|
use radicle::storage::ReadStorage;
|
||||||
|
use radicle::{git, identity};
|
||||||
|
|
||||||
|
use radicle_artifact::trust::{Scope, Trust};
|
||||||
|
use radicle_artifact::{Filters, ReleaseId, Releases as ArtifactStore, cache_db_path};
|
||||||
|
|
||||||
|
use crate::cobs;
|
||||||
|
pub use crate::cobs::release::{ReleaseFilter, ReleaseScope};
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::traits::Profile;
|
||||||
|
|
||||||
|
impl ReleaseFilter {
|
||||||
|
/// The crate's visibility rules for this view. No local user is trusted,
|
||||||
|
/// so the default view matches the `delegate` bucket of `counts()`.
|
||||||
|
fn filters(self, delegates: &BTreeSet<Did>) -> Filters<'_> {
|
||||||
|
Filters {
|
||||||
|
trust: Trust::new(delegates, None),
|
||||||
|
scope: match self.scope {
|
||||||
|
ReleaseScope::Trusted => Scope::Trusted,
|
||||||
|
ReleaseScope::Untrusted => Scope::Untrusted,
|
||||||
|
},
|
||||||
|
include_redacted: self.show_redacted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Releases: Profile {
|
||||||
|
/// List a repository's releases, newest first.
|
||||||
|
///
|
||||||
|
/// Scoped to releases created by a delegate and artifacts authored by a
|
||||||
|
/// delegate, or with an untrusted `filter` to those by non-delegates, so
|
||||||
|
/// the two scopes never overlap. Artifacts redacted by a trusted party are
|
||||||
|
/// hidden unless `filter` shows them. A release with no artifact in scope
|
||||||
|
/// shows every author's. A release whose artifacts were all redacted is
|
||||||
|
/// hidden with them; a release with no artifacts is shown. This is the view
|
||||||
|
/// `rad-artifact list` gives. Without `take` the full list is returned and
|
||||||
|
/// `skip` is ignored.
|
||||||
|
fn list_releases(
|
||||||
|
&self,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
filter: Option<ReleaseFilter>,
|
||||||
|
skip: Option<usize>,
|
||||||
|
take: Option<usize>,
|
||||||
|
) -> Result<cobs::PaginatedQuery<Vec<cobs::release::Release>>, Error> {
|
||||||
|
let profile = self.profile();
|
||||||
|
let repo = profile.storage.repository(rid)?;
|
||||||
|
let aliases = profile.aliases();
|
||||||
|
let filter = filter.unwrap_or_default();
|
||||||
|
|
||||||
|
// Read through the SQLite cache; it self-warms on read and is shared
|
||||||
|
// with other release reads on this node. `list` is sorted newest
|
||||||
|
// first and lazy, so a page reads only the rows up to its end.
|
||||||
|
let store = ArtifactStore::open_cached(&repo, cache_db_path(profile.cobs()))?;
|
||||||
|
let filters = filter.filters(store.delegates());
|
||||||
|
let releases = store
|
||||||
|
.list()?
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|(_, release)| filters.shows_release(release));
|
||||||
|
|
||||||
|
// With no artifact by an author in scope, fall back to every author, as
|
||||||
|
// the release page does, so a teaser never counts fewer artifacts than
|
||||||
|
// its page lists. A redacted artifact in scope keeps the scope.
|
||||||
|
let fallback = Filters {
|
||||||
|
scope: Scope::All,
|
||||||
|
..filters
|
||||||
|
};
|
||||||
|
let summary = |(id, release): (radicle::cob::ObjectId, radicle_artifact::Release)| {
|
||||||
|
let has_trusted = release
|
||||||
|
.artifacts()
|
||||||
|
.values()
|
||||||
|
.any(|artifact| filters.trust.admits(filters.scope, artifact.author()));
|
||||||
|
let artifacts = if has_trusted { filters } else { fallback }
|
||||||
|
.artifacts(&release)
|
||||||
|
.map(|(cid, artifact)| {
|
||||||
|
cobs::release::Artifact::new(cid, artifact, store.delegates(), &aliases)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
cobs::release::Release::new(ReleaseId::from(id), &release, &repo, &aliases, artifacts)
|
||||||
|
};
|
||||||
|
|
||||||
|
match take {
|
||||||
|
None => Ok(cobs::PaginatedQuery {
|
||||||
|
cursor: 0,
|
||||||
|
more: false,
|
||||||
|
content: releases.map(summary).collect::<Vec<_>>(),
|
||||||
|
}),
|
||||||
|
Some(take) => {
|
||||||
|
let cursor = skip.unwrap_or(0);
|
||||||
|
let mut content = releases
|
||||||
|
.skip(cursor)
|
||||||
|
.take(take + 1)
|
||||||
|
.map(summary)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let more = content.len() > take;
|
||||||
|
content.truncate(take);
|
||||||
|
|
||||||
|
Ok(cobs::PaginatedQuery {
|
||||||
|
cursor,
|
||||||
|
more,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a single release by id, with all of its artifacts. Filtering is left
|
||||||
|
/// to the caller so a release reached by a direct link is never empty.
|
||||||
|
fn release_by_id(
|
||||||
|
&self,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
id: git::Oid,
|
||||||
|
) -> Result<Option<cobs::release::Release>, Error> {
|
||||||
|
let profile = self.profile();
|
||||||
|
let repo = profile.storage.repository(rid)?;
|
||||||
|
let aliases = profile.aliases();
|
||||||
|
|
||||||
|
let store = ArtifactStore::open_cached(&repo, cache_db_path(profile.cobs()))?;
|
||||||
|
let release = store.get(&ReleaseId::from(id))?;
|
||||||
|
|
||||||
|
Ok(release.map(|release| {
|
||||||
|
let artifacts = release
|
||||||
|
.artifacts()
|
||||||
|
.iter()
|
||||||
|
.map(|(cid, artifact)| {
|
||||||
|
cobs::release::Artifact::new(cid, artifact, store.delegates(), &aliases)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
cobs::release::Release::new(ReleaseId::from(id), &release, &repo, &aliases, artifacts)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of releases in a repository, bucketed the same way the default
|
||||||
|
/// list filters them.
|
||||||
|
fn release_counts(&self, rid: identity::RepoId) -> Result<cobs::release::ReleaseCounts, Error> {
|
||||||
|
let profile = self.profile();
|
||||||
|
let repo = profile.storage.repository(rid)?;
|
||||||
|
let store = ArtifactStore::open_cached(&repo, cache_db_path(profile.cobs()))?;
|
||||||
|
|
||||||
|
Ok(store.counts()?.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
mod test {
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use radicle::crypto::{Seed, SigningKey};
|
||||||
|
use radicle::storage::ReadStorage;
|
||||||
|
use radicle::test::fixtures;
|
||||||
|
|
||||||
|
use radicle_artifact::{Cid, Releases as ArtifactStore};
|
||||||
|
|
||||||
|
use super::Releases;
|
||||||
|
use crate::cobs::release::{ReleaseFilter, ReleaseScope};
|
||||||
|
use crate::{AppState, test};
|
||||||
|
|
||||||
|
const CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi";
|
||||||
|
const OTHER_CID: &str = "bafkr4ihjtgc6jaulcccny3jhc3ezxfjdgnp4vvt23nf6qqlrhpk764ufbu";
|
||||||
|
|
||||||
|
/// Count the artifacts the default list shows for a delegate release
|
||||||
|
/// holding one non-delegate artifact, after `setup` adds to it.
|
||||||
|
fn listed_artifacts(
|
||||||
|
setup: impl FnOnce(
|
||||||
|
&mut radicle_artifact::ReleaseMut<'_, '_, radicle::storage::git::Repository>,
|
||||||
|
&SigningKey,
|
||||||
|
),
|
||||||
|
) -> usize {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||||
|
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||||
|
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||||
|
let (rid, _, _, head) =
|
||||||
|
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||||
|
{
|
||||||
|
let repo = profile.storage.repository(rid).unwrap();
|
||||||
|
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||||
|
let mut release = releases
|
||||||
|
.create(radicle::git::Oid::from(head), None, &signer)
|
||||||
|
.unwrap();
|
||||||
|
release
|
||||||
|
.register_artifact(Cid::from_str(OTHER_CID).unwrap(), "eve".into(), &other)
|
||||||
|
.unwrap();
|
||||||
|
setup(&mut release, &signer);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = AppState { profile };
|
||||||
|
let list = state.list_releases(rid, None, None, None).unwrap();
|
||||||
|
list.content[0].artifacts.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_falls_back_to_every_author_without_delegate_artifact() {
|
||||||
|
assert_eq!(listed_artifacts(|_, _| {}), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_keeps_delegate_scope_with_redacted_delegate_artifact() {
|
||||||
|
let count = listed_artifacts(|release, signer| {
|
||||||
|
let cid = Cid::from_str(CID).unwrap();
|
||||||
|
release
|
||||||
|
.register_artifact(cid, "bin".into(), signer)
|
||||||
|
.unwrap();
|
||||||
|
release.redact(cid, "bad build".into(), signer).unwrap();
|
||||||
|
});
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_pages_through_releases() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||||
|
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||||
|
let (rid, _, _, head) =
|
||||||
|
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||||
|
{
|
||||||
|
let repo = profile.storage.repository(rid).unwrap();
|
||||||
|
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||||
|
for seed in [0xaa, 0xbb, 0xcc] {
|
||||||
|
let other = SigningKey::from_seed(Seed::new([seed; 32]));
|
||||||
|
releases
|
||||||
|
.create(radicle::git::Oid::from(head), None, &other)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let state = AppState { profile };
|
||||||
|
let filter = ReleaseFilter {
|
||||||
|
scope: ReleaseScope::Untrusted,
|
||||||
|
show_redacted: false,
|
||||||
|
};
|
||||||
|
let page = |skip, take| state.list_releases(rid, Some(filter), skip, take).unwrap();
|
||||||
|
|
||||||
|
let all = page(None, None);
|
||||||
|
assert_eq!((all.content.len(), all.more), (3, false));
|
||||||
|
|
||||||
|
let first = page(Some(0), Some(2));
|
||||||
|
let second = page(Some(2), Some(2));
|
||||||
|
assert_eq!((first.cursor, first.more), (0, true));
|
||||||
|
assert_eq!((second.cursor, second.more), (2, false));
|
||||||
|
let ids = |pages: &[&[crate::cobs::release::Release]]| {
|
||||||
|
pages
|
||||||
|
.iter()
|
||||||
|
.flat_map(|p| p.iter().map(|r| r.id))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
ids(&[&first.content, &second.content]),
|
||||||
|
ids(&[&all.content])
|
||||||
|
);
|
||||||
|
|
||||||
|
let exact = page(Some(0), Some(3));
|
||||||
|
assert_eq!((exact.content.len(), exact.more), (3, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_scopes_split_delegates_from_others() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||||
|
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||||
|
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||||
|
let (rid, _, _, head) =
|
||||||
|
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||||
|
let (ours, theirs) = {
|
||||||
|
let repo = profile.storage.repository(rid).unwrap();
|
||||||
|
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||||
|
let oid = radicle::git::Oid::from(head);
|
||||||
|
let ours = releases.create(oid, None, &signer).unwrap().id().oid();
|
||||||
|
let theirs = releases.create(oid, None, &other).unwrap().id().oid();
|
||||||
|
(ours, theirs)
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = AppState { profile };
|
||||||
|
let listed = |scope| {
|
||||||
|
let filter = ReleaseFilter {
|
||||||
|
scope,
|
||||||
|
show_redacted: false,
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.list_releases(rid, Some(filter), None, None)
|
||||||
|
.unwrap()
|
||||||
|
.content
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.id)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(listed(ReleaseScope::Trusted), [ours]);
|
||||||
|
assert_eq!(listed(ReleaseScope::Untrusted), [theirs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_counts_split_delegates_from_others() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||||
|
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||||
|
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||||
|
let (rid, _, _, head) =
|
||||||
|
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||||
|
{
|
||||||
|
let repo = profile.storage.repository(rid).unwrap();
|
||||||
|
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||||
|
let oid = radicle::git::Oid::from(head);
|
||||||
|
releases.create(oid, None, &signer).unwrap();
|
||||||
|
let mut theirs = releases.create(oid, None, &other).unwrap();
|
||||||
|
let cid = Cid::from_str(OTHER_CID).unwrap();
|
||||||
|
theirs.register_artifact(cid, "eve".into(), &other).unwrap();
|
||||||
|
theirs.redact(cid, "oops".into(), &other).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let counts = AppState { profile }.release_counts(rid).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(
|
||||||
|
counts.delegate,
|
||||||
|
counts.delegate_redacted,
|
||||||
|
counts.other,
|
||||||
|
counts.other_redacted
|
||||||
|
),
|
||||||
|
(1, 0, 0, 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use radicle::identity::{self, Did};
|
||||||
|
use radicle::profile::Signer;
|
||||||
|
use radicle::storage::ReadStorage;
|
||||||
|
use radicle::storage::git::Repository;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use radicle_artifact::{Cid, ReleaseId, ReleaseMut, Releases as ArtifactStore};
|
||||||
|
use radicle_artifact_core::cid as cid_utils;
|
||||||
|
use radicle_artifact_core::keys::EndpointId;
|
||||||
|
|
||||||
|
use crate::cobs;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::traits::Profile;
|
||||||
|
use crate::traits::release::Releases;
|
||||||
|
|
||||||
|
/// Parse a content id, folding the multiformats parse error into our own so
|
||||||
|
/// the rest of the app need not depend on the `cid` crate.
|
||||||
|
pub(crate) fn parse_cid(cid: &str) -> Result<Cid, Error> {
|
||||||
|
Cid::from_str(cid).map_err(|err| radicle_artifact_core::Error::Cid(err.to_string()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a location URL, rejecting ones peers could not fetch from, as
|
||||||
|
/// `rad-artifact location add` does, so they are never signed into the COB.
|
||||||
|
fn parse_location(url: &str) -> Result<Url, Error> {
|
||||||
|
let url = Url::parse(url.trim()).map_err(|err| Error::InvalidLocation(err.to_string()))?;
|
||||||
|
if EndpointId::is_legacy_endpoint_url(&url) {
|
||||||
|
return Err(Error::InvalidLocation(
|
||||||
|
"the `iroh://` scheme was renamed to `radiroh://`".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if EndpointId::is_endpoint_url(&url) {
|
||||||
|
EndpointId::from_url(&url).map_err(|err| Error::InvalidLocation(err.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a release for writing and apply `f` to the artifact `cid` on it,
|
||||||
|
/// signed by the local user.
|
||||||
|
fn update_artifact(
|
||||||
|
ctx: &(impl Profile + ?Sized),
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: &str,
|
||||||
|
cid: &str,
|
||||||
|
f: impl FnOnce(&mut ReleaseMut<'_, '_, Repository>, Cid, &Signer) -> Result<(), Error>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let profile = ctx.profile();
|
||||||
|
let signer = profile.signer()?;
|
||||||
|
let repo = profile.storage.repository(rid)?;
|
||||||
|
|
||||||
|
let id = ReleaseId::from_str(release_id)?;
|
||||||
|
let cid = parse_cid(cid)?;
|
||||||
|
|
||||||
|
let mut releases = ArtifactStore::open(&repo)?;
|
||||||
|
let mut release = releases.get_mut(&id)?;
|
||||||
|
f(&mut release, cid, &signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ReleasesMut: Releases {
|
||||||
|
/// Content-address a file or directory on disk without involving the
|
||||||
|
/// artifact node, so a release can be assembled while the node is down.
|
||||||
|
///
|
||||||
|
/// Files hash as a single blob; directories hash as a collection over a
|
||||||
|
/// canonical walk. The size is returned alongside so the caller can
|
||||||
|
/// register the artifact and its size hint in one signed entry.
|
||||||
|
fn compute_artifact_cid(&self, path: PathBuf) -> Result<cobs::release::ArtifactDigest, Error> {
|
||||||
|
let directory = path.is_dir();
|
||||||
|
let cid = if directory {
|
||||||
|
cid_utils::compute_content_id(&path)?
|
||||||
|
} else {
|
||||||
|
cid_utils::compute_blob_cid(&path)?
|
||||||
|
};
|
||||||
|
let file_count = if directory {
|
||||||
|
cid_utils::canonical_walk(&path)?.len() as u64
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(cobs::release::ArtifactDigest {
|
||||||
|
cid: cid.to_string(),
|
||||||
|
size_bytes: cid_utils::compute_size_from_path(&path)?,
|
||||||
|
file_count,
|
||||||
|
directory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the release for a commit, or create one. Returns the release id.
|
||||||
|
///
|
||||||
|
/// Idempotent: re-running against the same commit reuses the existing
|
||||||
|
/// release rather than creating a second one. As with `rad-artifact
|
||||||
|
/// release create`, only a release the local user created is reused, so
|
||||||
|
/// artifacts never land under another peer's release. With `tag`, only a
|
||||||
|
/// release carrying that exact tag is reused; without it, the newest one
|
||||||
|
/// for the commit is. Pass `None` for lightweight tags and bare commits,
|
||||||
|
/// since the store accepts only an annotated tag that peels to `oid`.
|
||||||
|
fn create_or_open_release(
|
||||||
|
&self,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
oid: radicle::git::Oid,
|
||||||
|
tag: Option<radicle::git::Oid>,
|
||||||
|
) -> Result<String, Error> {
|
||||||
|
let profile = self.profile();
|
||||||
|
let signer = profile.signer()?;
|
||||||
|
let repo = profile.storage.repository(rid)?;
|
||||||
|
|
||||||
|
if repo.backend.find_commit(oid.into()).is_err() {
|
||||||
|
return Err(Error::CommitNotFound(oid));
|
||||||
|
}
|
||||||
|
|
||||||
|
let local = Did::from(profile.public_key);
|
||||||
|
|
||||||
|
let mut releases = ArtifactStore::open(&repo)?;
|
||||||
|
|
||||||
|
let existing = releases
|
||||||
|
.find_by_commit(oid)?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, release)| {
|
||||||
|
release.creator() == &local && tag.is_none_or(|tag| release.tag() == Some(&tag))
|
||||||
|
})
|
||||||
|
.max_by_key(|(_, release)| release.timestamp())
|
||||||
|
.map(|(id, _)| id);
|
||||||
|
|
||||||
|
let id = match existing {
|
||||||
|
Some(id) => id,
|
||||||
|
None => *releases.create(oid, tag, &signer)?.id(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register an artifact under a release, recording its size hint in the
|
||||||
|
/// same signed entry.
|
||||||
|
fn register_artifact(
|
||||||
|
&self,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
name: String,
|
||||||
|
size_bytes: u64,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||||
|
release.register_artifact_with_size(cid, name, size_bytes, signer)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Announce a place an artifact can be fetched from.
|
||||||
|
fn add_location(
|
||||||
|
&self,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let url = parse_location(&url)?;
|
||||||
|
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||||
|
release.add_location(cid, url, signer)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Withdraw a location this node previously announced.
|
||||||
|
fn remove_location(
|
||||||
|
&self,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let url = Url::parse(&url)?;
|
||||||
|
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||||
|
release.remove_location(cid, url, signer)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vouch for an artifact by reproducing it: hash a local build at `path`
|
||||||
|
/// and sign an attestation only when it arrives at the same content id.
|
||||||
|
fn attest_artifact(
|
||||||
|
&self,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
path: PathBuf,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let expected = parse_cid(&cid)?;
|
||||||
|
let actual = parse_cid(&self.compute_artifact_cid(path)?.cid)?;
|
||||||
|
if actual != expected {
|
||||||
|
return Err(radicle_artifact_core::Error::CidMismatch {
|
||||||
|
expected: expected.to_string(),
|
||||||
|
actual: actual.to_string(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||||
|
release.attest(cid, signer)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a free-form metadata key on an artifact.
|
||||||
|
fn set_artifact_metadata(
|
||||||
|
&self,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
key: String,
|
||||||
|
value: serde_json::Value,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||||
|
release.set_metadata(cid, key, value, signer)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a metadata key from an artifact.
|
||||||
|
fn remove_artifact_metadata(
|
||||||
|
&self,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
key: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||||
|
release.remove_metadata(cid, key, signer)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flag an artifact as one that should no longer be fetched.
|
||||||
|
fn redact_artifact(
|
||||||
|
&self,
|
||||||
|
rid: identity::RepoId,
|
||||||
|
release_id: String,
|
||||||
|
cid: String,
|
||||||
|
reason: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
update_artifact(self, rid, &release_id, &cid, |release, cid, signer| {
|
||||||
|
release.redact(cid, reason, signer)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the local user's ref to a release.
|
||||||
|
///
|
||||||
|
/// The release disappears once no peer has a ref to it. Actions other
|
||||||
|
/// peers built on stay visible, so a release others added to remains.
|
||||||
|
fn delete_release(&self, rid: identity::RepoId, release_id: String) -> Result<(), Error> {
|
||||||
|
let profile = self.profile();
|
||||||
|
let signer = profile.signer()?;
|
||||||
|
let repo = profile.storage.repository(rid)?;
|
||||||
|
|
||||||
|
let id = ReleaseId::from_str(&release_id)?;
|
||||||
|
|
||||||
|
let mut releases = ArtifactStore::open(&repo)?;
|
||||||
|
releases.remove(&id, &signer)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
pub(crate) mod test {
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use radicle::crypto::{Seed, Signer, SigningKey};
|
||||||
|
use radicle::identity::RepoId;
|
||||||
|
use radicle::storage::ReadStorage;
|
||||||
|
use radicle::test::fixtures;
|
||||||
|
|
||||||
|
use radicle_artifact::Releases as ArtifactStore;
|
||||||
|
|
||||||
|
use super::ReleasesMut;
|
||||||
|
use crate::cobs::release::Release;
|
||||||
|
use crate::traits::release::Releases;
|
||||||
|
use crate::{AppState, test};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_or_open_release_skips_other_creators() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||||
|
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||||
|
let (rid, _, _, head) =
|
||||||
|
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||||
|
let oid = radicle::git::Oid::from(head);
|
||||||
|
|
||||||
|
// Another peer releases the same commit first.
|
||||||
|
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||||
|
let repo = profile.storage.repository(rid).unwrap();
|
||||||
|
let theirs = *ArtifactStore::open(&repo)
|
||||||
|
.unwrap()
|
||||||
|
.create(oid, None, &other)
|
||||||
|
.unwrap()
|
||||||
|
.id();
|
||||||
|
|
||||||
|
let state = AppState { profile };
|
||||||
|
let ours = state.create_or_open_release(rid, oid, None).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(ours, theirs.to_string());
|
||||||
|
assert_eq!(state.create_or_open_release(rid, oid, None).unwrap(), ours);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_or_open_release_rejects_unknown_commit() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||||
|
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||||
|
let (rid, _, _, _) =
|
||||||
|
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||||
|
let unknown =
|
||||||
|
radicle::git::Oid::from_str("cafecafecafecafecafecafecafecafecafecafe").unwrap();
|
||||||
|
|
||||||
|
let state = AppState { profile };
|
||||||
|
let err = state
|
||||||
|
.create_or_open_release(rid, unknown, None)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err.code(), "RepoError.CommitNotFound");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A release on a fresh project with one registered artifact.
|
||||||
|
pub(crate) fn release_with_artifact(
|
||||||
|
tmp: &std::path::Path,
|
||||||
|
) -> (AppState, RepoId, String, String) {
|
||||||
|
let profile = test::profile(&tmp.join("home"), [0xff; 32]);
|
||||||
|
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||||
|
let (rid, _, _, head) =
|
||||||
|
fixtures::project(tmp.join("project"), &profile.storage, &signer).unwrap();
|
||||||
|
|
||||||
|
let state = AppState { profile };
|
||||||
|
let id = state
|
||||||
|
.create_or_open_release(rid, radicle::git::Oid::from(head), None)
|
||||||
|
.unwrap();
|
||||||
|
let file = tmp.join("build.tar");
|
||||||
|
std::fs::write(&file, "artifact bytes").unwrap();
|
||||||
|
let digest = state.compute_artifact_cid(file).unwrap();
|
||||||
|
state
|
||||||
|
.register_artifact(rid, id.clone(), digest.cid.clone(), "build.tar".into(), 14)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
(state, rid, id, digest.cid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn locations(state: &AppState, rid: RepoId, id: &str, cid: &str) -> Vec<String> {
|
||||||
|
let repo = state.profile.storage.repository(rid).unwrap();
|
||||||
|
let id = radicle_artifact::ReleaseId::from_str(id).unwrap();
|
||||||
|
let cid = super::parse_cid(cid).unwrap();
|
||||||
|
let release = ArtifactStore::open(&repo)
|
||||||
|
.unwrap()
|
||||||
|
.get(&id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
release
|
||||||
|
.artifact(&cid)
|
||||||
|
.unwrap()
|
||||||
|
.locations()
|
||||||
|
.values()
|
||||||
|
.flatten()
|
||||||
|
.map(|url| url.to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_and_remove_location() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||||
|
let url = "https://example.com/build.tar";
|
||||||
|
|
||||||
|
state
|
||||||
|
.add_location(rid, id.clone(), cid.clone(), url.into())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(locations(&state, rid, &id, &cid), [url]);
|
||||||
|
|
||||||
|
state
|
||||||
|
.remove_location(rid, id.clone(), cid.clone(), url.into())
|
||||||
|
.unwrap();
|
||||||
|
assert!(locations(&state, rid, &id, &cid).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locations_are_listed_per_contributor() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||||
|
let url = "https://example.com/build.tar";
|
||||||
|
state
|
||||||
|
.add_location(rid, id.clone(), cid.clone(), url.into())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||||
|
{
|
||||||
|
let repo = state.profile.storage.repository(rid).unwrap();
|
||||||
|
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||||
|
let mut release = releases
|
||||||
|
.get_mut(&radicle_artifact::ReleaseId::from_str(&id).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
for url in [url, "https://mirror.example.com/build.tar"] {
|
||||||
|
release
|
||||||
|
.add_location(
|
||||||
|
super::parse_cid(&cid).unwrap(),
|
||||||
|
url::Url::parse(url).unwrap(),
|
||||||
|
&other,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut locations = read_release(&state, rid, &id).unwrap().artifacts[0]
|
||||||
|
.locations
|
||||||
|
.iter()
|
||||||
|
.map(|l| (*l.user.did(), l.url.clone()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
locations.sort();
|
||||||
|
let me = radicle::identity::Did::from(state.profile.public_key);
|
||||||
|
let them = radicle::identity::Did::from(*other.public_key());
|
||||||
|
let mut expected = vec![
|
||||||
|
(me, url.to_string()),
|
||||||
|
(them, url.to_string()),
|
||||||
|
(them, "https://mirror.example.com/build.tar".to_string()),
|
||||||
|
];
|
||||||
|
expected.sort();
|
||||||
|
assert_eq!(locations, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artifacts_say_whether_they_are_folders() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (state, rid, id, file_cid) = release_with_artifact(tmp.path());
|
||||||
|
let folder = tmp.path().join("dist");
|
||||||
|
std::fs::create_dir(&folder).unwrap();
|
||||||
|
std::fs::write(folder.join("app"), "folder bytes").unwrap();
|
||||||
|
let digest = state.compute_artifact_cid(folder).unwrap();
|
||||||
|
state
|
||||||
|
.register_artifact(rid, id.clone(), digest.cid.clone(), "dist".into(), 12)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let release = read_release(&state, rid, &id).unwrap();
|
||||||
|
let directory = |cid: &str| {
|
||||||
|
release
|
||||||
|
.artifacts
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.cid == cid)
|
||||||
|
.unwrap()
|
||||||
|
.directory
|
||||||
|
};
|
||||||
|
assert!(!directory(&file_cid));
|
||||||
|
assert!(directory(&digest.cid));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_location_rejects_unfetchable_urls() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||||
|
|
||||||
|
for url in ["not a url", "iroh://abc", "radiroh://not-base32!"] {
|
||||||
|
let err = state
|
||||||
|
.add_location(rid, id.clone(), cid.clone(), url.into())
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.code(), "ArtifactError.InvalidLocation", "{url}");
|
||||||
|
}
|
||||||
|
assert!(locations(&state, rid, &id, &cid).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_release_removes_own_release() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let profile = test::profile(&tmp.path().join("home"), [0xff; 32]);
|
||||||
|
let signer = SigningKey::from_seed(Seed::new([0xff; 32]));
|
||||||
|
let (rid, _, _, head) =
|
||||||
|
fixtures::project(tmp.path().join("project"), &profile.storage, &signer).unwrap();
|
||||||
|
let oid = radicle::git::Oid::from(head);
|
||||||
|
|
||||||
|
let state = AppState { profile };
|
||||||
|
let id = state.create_or_open_release(rid, oid, None).unwrap();
|
||||||
|
state.delete_release(rid, id.clone()).unwrap();
|
||||||
|
|
||||||
|
let repo = state.profile.storage.repository(rid).unwrap();
|
||||||
|
let id = radicle_artifact::ReleaseId::from_str(&id).unwrap();
|
||||||
|
assert!(
|
||||||
|
ArtifactStore::open(&repo)
|
||||||
|
.unwrap()
|
||||||
|
.get(&id)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The release as the app reads it.
|
||||||
|
pub(crate) fn read_release(state: &AppState, rid: RepoId, id: &str) -> Option<Release> {
|
||||||
|
state
|
||||||
|
.release_by_id(rid, radicle::git::Oid::from_str(id).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register `bytes` under the release as a peer who is not a delegate.
|
||||||
|
fn register_as_other(
|
||||||
|
state: &AppState,
|
||||||
|
tmp: &std::path::Path,
|
||||||
|
rid: RepoId,
|
||||||
|
id: &str,
|
||||||
|
bytes: &str,
|
||||||
|
) -> (std::path::PathBuf, String) {
|
||||||
|
let file = tmp.join("theirs.tar");
|
||||||
|
std::fs::write(&file, bytes).unwrap();
|
||||||
|
let cid = state.compute_artifact_cid(file.clone()).unwrap().cid;
|
||||||
|
|
||||||
|
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||||
|
let repo = state.profile.storage.repository(rid).unwrap();
|
||||||
|
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||||
|
let mut release = releases
|
||||||
|
.get_mut(&radicle_artifact::ReleaseId::from_str(id).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
release
|
||||||
|
.register_artifact(super::parse_cid(&cid).unwrap(), "theirs.tar".into(), &other)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
(file, cid)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attest_artifact_signs_a_matching_build() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (state, rid, id, _) = release_with_artifact(tmp.path());
|
||||||
|
let (file, cid) = register_as_other(&state, tmp.path(), rid, &id, "their bytes");
|
||||||
|
|
||||||
|
state
|
||||||
|
.attest_artifact(rid, id.clone(), cid.clone(), file)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let release = read_release(&state, rid, &id).unwrap();
|
||||||
|
let artifact = release.artifacts.iter().find(|a| a.cid == cid).unwrap();
|
||||||
|
assert_eq!(artifact.attestations.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attest_artifact_rejects_a_different_build() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (state, rid, id, _) = release_with_artifact(tmp.path());
|
||||||
|
let (_, cid) = register_as_other(&state, tmp.path(), rid, &id, "their bytes");
|
||||||
|
let build = tmp.path().join("mine.tar");
|
||||||
|
std::fs::write(&build, "other bytes").unwrap();
|
||||||
|
|
||||||
|
let err = state
|
||||||
|
.attest_artifact(rid, id.clone(), cid.clone(), build)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err.code(), "ArtifactError.CidMismatch");
|
||||||
|
let release = read_release(&state, rid, &id).unwrap();
|
||||||
|
let artifact = release.artifacts.iter().find(|a| a.cid == cid).unwrap();
|
||||||
|
assert!(artifact.attestations.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_and_remove_artifact_metadata() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||||
|
let metadata = |state: &AppState| {
|
||||||
|
read_release(state, rid, &id).unwrap().artifacts[0]
|
||||||
|
.metadata
|
||||||
|
.get("arch")
|
||||||
|
.cloned()
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.set_artifact_metadata(rid, id.clone(), cid.clone(), "arch".into(), "x86_64".into())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(metadata(&state), Some("x86_64".into()));
|
||||||
|
|
||||||
|
state
|
||||||
|
.remove_artifact_metadata(rid, id.clone(), cid.clone(), "arch".into())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(metadata(&state), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_from_a_non_author_is_ignored() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||||
|
|
||||||
|
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||||
|
{
|
||||||
|
let repo = state.profile.storage.repository(rid).unwrap();
|
||||||
|
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||||
|
let mut release = releases
|
||||||
|
.get_mut(&radicle_artifact::ReleaseId::from_str(&id).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
release
|
||||||
|
.set_metadata(
|
||||||
|
super::parse_cid(&cid).unwrap(),
|
||||||
|
"arch".into(),
|
||||||
|
"evil".into(),
|
||||||
|
&other,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let release = read_release(&state, rid, &id).unwrap();
|
||||||
|
assert_eq!(release.artifacts[0].metadata.get("arch"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redact_artifact_hides_it_and_keeps_the_reason() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||||
|
|
||||||
|
state
|
||||||
|
.redact_artifact(rid, id.clone(), cid, "bad build".into())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let artifact = &read_release(&state, rid, &id).unwrap().artifacts[0];
|
||||||
|
assert!(artifact.redacted);
|
||||||
|
assert_eq!(artifact.redactions[0].reason, "bad build");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redaction_by_a_non_delegate_does_not_hide() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let (state, rid, id, cid) = release_with_artifact(tmp.path());
|
||||||
|
|
||||||
|
let other = SigningKey::from_seed(Seed::new([0xee; 32]));
|
||||||
|
{
|
||||||
|
let repo = state.profile.storage.repository(rid).unwrap();
|
||||||
|
let mut releases = ArtifactStore::open(&repo).unwrap();
|
||||||
|
let mut release = releases
|
||||||
|
.get_mut(&radicle_artifact::ReleaseId::from_str(&id).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
release
|
||||||
|
.redact(super::parse_cid(&cid).unwrap(), "spite".into(), &other)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let artifact = &read_release(&state, rid, &id).unwrap().artifacts[0];
|
||||||
|
assert!(!artifact.redacted);
|
||||||
|
assert_eq!(artifact.redactions.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -297,6 +297,7 @@ fn resolve_tag(repo: &storage::git::Repository, oid: git::raw::Oid) -> Option<re
|
|||||||
.unwrap_or_else(|| commit.time().seconds());
|
.unwrap_or_else(|| commit.time().seconds());
|
||||||
return Some(repo::Tag {
|
return Some(repo::Tag {
|
||||||
oid: commit.id().into(),
|
oid: commit.id().into(),
|
||||||
|
tag_oid: Some(oid.into()),
|
||||||
timestamp,
|
timestamp,
|
||||||
tagger,
|
tagger,
|
||||||
message: tag.message().ok().flatten().map(str::to_owned),
|
message: tag.message().ok().flatten().map(str::to_owned),
|
||||||
@@ -305,6 +306,7 @@ fn resolve_tag(repo: &storage::git::Repository, oid: git::raw::Oid) -> Option<re
|
|||||||
let commit = repo.backend.find_commit(oid).ok()?;
|
let commit = repo.backend.find_commit(oid).ok()?;
|
||||||
Some(repo::Tag {
|
Some(repo::Tag {
|
||||||
oid: commit.id().into(),
|
oid: commit.id().into(),
|
||||||
|
tag_oid: None,
|
||||||
timestamp: commit.time().seconds(),
|
timestamp: commit.time().seconds(),
|
||||||
tagger: None,
|
tagger: None,
|
||||||
message: None,
|
message: None,
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ use radicle_types::traits::inbox::Inbox;
|
|||||||
use radicle_types::traits::issue::{Issues, IssuesMut};
|
use radicle_types::traits::issue::{Issues, IssuesMut};
|
||||||
use radicle_types::traits::job::Jobs;
|
use radicle_types::traits::job::Jobs;
|
||||||
use radicle_types::traits::patch::{Patches, PatchesMut};
|
use radicle_types::traits::patch::{Patches, PatchesMut};
|
||||||
|
use radicle_types::traits::release::{ReleaseFilter, Releases};
|
||||||
|
use radicle_types::traits::release_mut::ReleasesMut;
|
||||||
use radicle_types::traits::repo::{Repo, Show};
|
use radicle_types::traits::repo::{Repo, Show};
|
||||||
use radicle_types::traits::thread::Thread;
|
use radicle_types::traits::thread::Thread;
|
||||||
|
|
||||||
@@ -64,6 +66,8 @@ impl IssuesMut for Context {}
|
|||||||
impl Jobs for Context {}
|
impl Jobs for Context {}
|
||||||
impl Patches for Context {}
|
impl Patches for Context {}
|
||||||
impl PatchesMut for Context {}
|
impl PatchesMut for Context {}
|
||||||
|
impl Releases for Context {}
|
||||||
|
impl ReleasesMut for Context {}
|
||||||
impl Profile for Context {
|
impl Profile for Context {
|
||||||
fn profile(&self) -> radicle::Profile {
|
fn profile(&self) -> radicle::Profile {
|
||||||
self.profile.deref().clone()
|
self.profile.deref().clone()
|
||||||
@@ -138,6 +142,30 @@ pub fn router(shared: Shared) -> Router {
|
|||||||
.route("/edit_patch", post(edit_patch_handler))
|
.route("/edit_patch", post(edit_patch_handler))
|
||||||
.route("/create_patch_review", post(create_patch_review_handler))
|
.route("/create_patch_review", post(create_patch_review_handler))
|
||||||
.route("/delete_patch", post(delete_patch_handler))
|
.route("/delete_patch", post(delete_patch_handler))
|
||||||
|
.route(
|
||||||
|
"/create_or_open_release",
|
||||||
|
post(create_or_open_release_handler),
|
||||||
|
)
|
||||||
|
.route("/register_artifact", post(register_artifact_handler))
|
||||||
|
.route("/attest_artifact", post(attest_artifact_handler))
|
||||||
|
.route(
|
||||||
|
"/set_artifact_metadata",
|
||||||
|
post(set_artifact_metadata_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/remove_artifact_metadata",
|
||||||
|
post(remove_artifact_metadata_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/add_artifact_location",
|
||||||
|
post(add_artifact_location_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/remove_artifact_location",
|
||||||
|
post(remove_artifact_location_handler),
|
||||||
|
)
|
||||||
|
.route("/redact_artifact", post(redact_artifact_handler))
|
||||||
|
.route("/delete_release", post(delete_release_handler))
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
Arc::new(Mutex::new(())),
|
Arc::new(Mutex::new(())),
|
||||||
serialize_writes,
|
serialize_writes,
|
||||||
@@ -197,6 +225,10 @@ pub fn router(shared: Shared) -> Router {
|
|||||||
.route("/revisions_by_patch", post(revision_handler))
|
.route("/revisions_by_patch", post(revision_handler))
|
||||||
.route("/get_embed", post(get_embeds_handler))
|
.route("/get_embed", post(get_embeds_handler))
|
||||||
.route("/save_embed_to_disk", post(save_embed_to_disk_handler))
|
.route("/save_embed_to_disk", post(save_embed_to_disk_handler))
|
||||||
|
.route("/list_releases", post(releases_handler))
|
||||||
|
.route("/release_by_id", post(release_handler))
|
||||||
|
.route("/release_counts", post(release_counts_handler))
|
||||||
|
.route("/compute_artifact_cid", post(compute_artifact_cid_handler))
|
||||||
.route("/list_jobs", post(jobs_handler))
|
.route("/list_jobs", post(jobs_handler))
|
||||||
.route("/list_notifications", post(list_notifications_handler))
|
.route("/list_notifications", post(list_notifications_handler))
|
||||||
.route("/notification_count", post(notification_count_handler))
|
.route("/notification_count", post(notification_count_handler))
|
||||||
@@ -983,6 +1015,264 @@ async fn issue_threads_handler(
|
|||||||
Ok::<_, Error>(Json(issue_threads))
|
Ok::<_, Error>(Json(issue_threads))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ReleasesBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
pub filter: Option<ReleaseFilter>,
|
||||||
|
pub skip: Option<usize>,
|
||||||
|
pub take: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn releases_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(ReleasesBody {
|
||||||
|
rid,
|
||||||
|
filter,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
}): Json<ReleasesBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let releases = ctx.list_releases(rid, filter, skip, take)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(releases))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ReleaseBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
pub id: git::Oid,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(ReleaseBody { rid, id }): Json<ReleaseBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let release = ctx.release_by_id(rid, id)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(release))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ReleaseCountsBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_counts_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(ReleaseCountsBody { rid }): Json<ReleaseCountsBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let counts = ctx.release_counts(rid)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(counts))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ComputeArtifactCidBody {
|
||||||
|
pub path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_artifact_cid_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(ComputeArtifactCidBody { path }): Json<ComputeArtifactCidBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let digest = ctx.compute_artifact_cid(path)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CreateOrOpenReleaseBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
pub oid: git::Oid,
|
||||||
|
pub tag: Option<git::Oid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_or_open_release_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(CreateOrOpenReleaseBody { rid, oid, tag }): Json<CreateOrOpenReleaseBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let id = ctx.create_or_open_release(rid, oid, tag)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RegisterArtifactBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
pub release_id: String,
|
||||||
|
pub cid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register_artifact_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(RegisterArtifactBody {
|
||||||
|
rid,
|
||||||
|
release_id,
|
||||||
|
cid,
|
||||||
|
name,
|
||||||
|
size_bytes,
|
||||||
|
}): Json<RegisterArtifactBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ctx.register_artifact(rid, release_id, cid, name, size_bytes)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AttestArtifactBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
pub release_id: String,
|
||||||
|
pub cid: String,
|
||||||
|
pub path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attest_artifact_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(AttestArtifactBody {
|
||||||
|
rid,
|
||||||
|
release_id,
|
||||||
|
cid,
|
||||||
|
path,
|
||||||
|
}): Json<AttestArtifactBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ctx.attest_artifact(rid, release_id, cid, path)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SetArtifactMetadataBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
pub release_id: String,
|
||||||
|
pub cid: String,
|
||||||
|
pub key: String,
|
||||||
|
pub value: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_artifact_metadata_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(SetArtifactMetadataBody {
|
||||||
|
rid,
|
||||||
|
release_id,
|
||||||
|
cid,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
}): Json<SetArtifactMetadataBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ctx.set_artifact_metadata(rid, release_id, cid, key, value)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RemoveArtifactMetadataBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
pub release_id: String,
|
||||||
|
pub cid: String,
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_artifact_metadata_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(RemoveArtifactMetadataBody {
|
||||||
|
rid,
|
||||||
|
release_id,
|
||||||
|
cid,
|
||||||
|
key,
|
||||||
|
}): Json<RemoveArtifactMetadataBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ctx.remove_artifact_metadata(rid, release_id, cid, key)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ArtifactLocationBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
pub release_id: String,
|
||||||
|
pub cid: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_artifact_location_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(ArtifactLocationBody {
|
||||||
|
rid,
|
||||||
|
release_id,
|
||||||
|
cid,
|
||||||
|
url,
|
||||||
|
}): Json<ArtifactLocationBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ctx.add_location(rid, release_id, cid, url)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_artifact_location_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(ArtifactLocationBody {
|
||||||
|
rid,
|
||||||
|
release_id,
|
||||||
|
cid,
|
||||||
|
url,
|
||||||
|
}): Json<ArtifactLocationBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ctx.remove_location(rid, release_id, cid, url)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct RedactArtifactBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
pub release_id: String,
|
||||||
|
pub cid: String,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn redact_artifact_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(RedactArtifactBody {
|
||||||
|
rid,
|
||||||
|
release_id,
|
||||||
|
cid,
|
||||||
|
reason,
|
||||||
|
}): Json<RedactArtifactBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ctx.redact_artifact(rid, release_id, cid, reason)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DeleteReleaseBody {
|
||||||
|
pub rid: identity::RepoId,
|
||||||
|
pub release_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_release_handler(
|
||||||
|
Ctx(ctx): Ctx,
|
||||||
|
Json(DeleteReleaseBody { rid, release_id }): Json<DeleteReleaseBody>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ctx.delete_release(rid, release_id)?;
|
||||||
|
|
||||||
|
Ok::<_, Error>(Json(()))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct PatchesBody {
|
struct PatchesBody {
|
||||||
|
|||||||
@@ -6,7 +6,16 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Tauri commands with no HTTP route, and why.
|
/// Tauri commands with no HTTP route, and why.
|
||||||
const TAURI_ONLY: &[(&str, &str)] = &[];
|
const TAURI_ONLY: &[(&str, &str)] = &[
|
||||||
|
(
|
||||||
|
"pick_artifact_files",
|
||||||
|
"opens a native file dialog, which the HTTP server has no access to",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pick_artifact_directory",
|
||||||
|
"opens a native file dialog, which the HTTP server has no access to",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
/// HTTP routes with no Tauri command, and why.
|
/// HTTP routes with no Tauri command, and why.
|
||||||
const HTTP_ONLY: &[(&str, &str)] = &[(
|
const HTTP_ONLY: &[(&str, &str)] = &[(
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ and a generic `Service` over it. The `Sqlite` adapter in `src/outbound/`
|
|||||||
implements those ports by querying Radicle's COB cache and notifications
|
implements those ports by querying Radicle's COB cache and notifications
|
||||||
database directly (read-only). Both drivers construct the services at
|
database directly (read-only). Both drivers construct the services at
|
||||||
startup and use them for the matching list endpoints; everything else
|
startup and use them for the matching list endpoints; everything else
|
||||||
keeps going through the ports.
|
keeps going through the ports. Releases are the exception: `radicle-artifact`
|
||||||
|
keeps its own SQLite cache of release COBs, so `list_releases` reads through
|
||||||
|
that instead of a domain service.
|
||||||
|
|
||||||
**Parity is enforced.** Every Tauri command has a matching HTTP route.
|
**Parity is enforced.** Every Tauri command has a matching HTTP route.
|
||||||
`cargo test -p test-http-api --test parity` fails when a command, route or
|
`cargo test -p test-http-api --test parity` fails when a command, route or
|
||||||
|
|||||||
Reference in New Issue
Block a user