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:
Daniel Norman
2026-10-08 18:20:59 +02:00
committed by Rūdolfs Ošiņš
co-authored by Brandon Oxendine Rūdolfs Ošiņš
parent 2ffcf1b95a
commit 7a0b67da64
26 changed files with 2520 additions and 57 deletions
Generated
+637 -55
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,6 +12,7 @@ use crate::AppState;
pub mod issue;
pub mod job;
pub mod patch;
pub mod release;
#[tauri::command]
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
}
+15
View File
@@ -83,6 +83,21 @@ pub fn run() {
cob::patch::create_patch_review,
cob::patch::rebuild_patch_cache,
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_path,
cob::save_embed_to_disk,
+2
View File
@@ -15,6 +15,8 @@ log = { version = "0.4.22" }
infer = { version = "0.19.0" }
mime-infer = { version = "3.0.0" }
radicle = { version = "0.25", features = ["tor", "i2p"] }
radicle-artifact = { version = "0.22" }
radicle-artifact-core = { version = "0.22" }
radicle-job = { version = "0.6" }
radicle-localtime = { version = "0.1.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 = {
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
* tagged commit. Seconds since epoch.
+1
View File
@@ -11,6 +11,7 @@ use radicle::node::{Alias, AliasStore};
pub mod diff;
pub mod issue;
pub mod job;
pub mod release;
pub mod repo;
pub mod stream;
pub mod thread;
+224
View File
@@ -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,
}
}
}
+37
View File
@@ -154,6 +154,14 @@ pub enum Error {
#[error("revision not found: {0}")]
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.
#[error(transparent)]
Surf(#[from] radicle_surf::Error),
@@ -189,6 +197,30 @@ pub enum Error {
/// Serde JSON error.
#[error(transparent)]
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 {
@@ -217,6 +249,11 @@ impl Error {
}
Error::FileTooLarge(_) => "PayloadError.TooLarge",
Error::ReviewExists => "PatchError.ReviewExists",
Error::CommitNotFound(_) => "RepoError.CommitNotFound",
Error::InvalidLocation(_) => "ArtifactError.InvalidLocation",
Error::ArtifactCore(radicle_artifact_core::Error::CidMismatch { .. }) => {
"ArtifactError.CidMismatch"
}
_ => "UnknownError",
}
}
+4
View File
@@ -5,6 +5,8 @@ use traits::inbox::Inbox;
use traits::issue::{Issues, IssuesMut};
use traits::job::Jobs;
use traits::patch::{Patches, PatchesMut};
use traits::release::Releases;
use traits::release_mut::ReleasesMut;
use traits::repo::Repo;
use traits::thread::Thread;
@@ -37,6 +39,8 @@ impl IssuesMut for AppState {}
impl Jobs for AppState {}
impl Patches for AppState {}
impl PatchesMut for AppState {}
impl Releases for AppState {}
impl ReleasesMut for AppState {}
impl Profile for AppState {
fn profile(&self) -> radicle::Profile {
self.profile.clone()
+6
View File
@@ -217,6 +217,12 @@ pub struct Tagger {
pub struct Tag {
#[ts(as = "String")]
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
/// tagged commit. Seconds since epoch.
#[ts(type = "number")]
+2
View File
@@ -16,6 +16,8 @@ pub mod inbox;
pub mod issue;
pub mod job;
pub mod patch;
pub mod release;
pub mod release_mut;
pub mod repo;
pub mod thread;
+326
View File
@@ -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);
}
}
+2
View File
@@ -297,6 +297,7 @@ fn resolve_tag(repo: &storage::git::Repository, oid: git::raw::Oid) -> Option<re
.unwrap_or_else(|| commit.time().seconds());
return Some(repo::Tag {
oid: commit.id().into(),
tag_oid: Some(oid.into()),
timestamp,
tagger,
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()?;
Some(repo::Tag {
oid: commit.id().into(),
tag_oid: None,
timestamp: commit.time().seconds(),
tagger: None,
message: None,
+290
View File
@@ -43,6 +43,8 @@ use radicle_types::traits::inbox::Inbox;
use radicle_types::traits::issue::{Issues, IssuesMut};
use radicle_types::traits::job::Jobs;
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::thread::Thread;
@@ -64,6 +66,8 @@ impl IssuesMut for Context {}
impl Jobs for Context {}
impl Patches for Context {}
impl PatchesMut for Context {}
impl Releases for Context {}
impl ReleasesMut for Context {}
impl Profile for Context {
fn profile(&self) -> radicle::Profile {
self.profile.deref().clone()
@@ -138,6 +142,30 @@ pub fn router(shared: Shared) -> Router {
.route("/edit_patch", post(edit_patch_handler))
.route("/create_patch_review", post(create_patch_review_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(
Arc::new(Mutex::new(())),
serialize_writes,
@@ -197,6 +225,10 @@ pub fn router(shared: Shared) -> Router {
.route("/revisions_by_patch", post(revision_handler))
.route("/get_embed", post(get_embeds_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_notifications", post(list_notifications_handler))
.route("/notification_count", post(notification_count_handler))
@@ -983,6 +1015,264 @@ async fn issue_threads_handler(
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)]
#[serde(rename_all = "camelCase")]
struct PatchesBody {
+10 -1
View File
@@ -6,7 +6,16 @@ use std::fs;
use std::path::{Path, PathBuf};
/// 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.
const HTTP_ONLY: &[(&str, &str)] = &[(
+3 -1
View File
@@ -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
database directly (read-only). Both drivers construct the services at
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.
`cargo test -p test-http-api --test parity` fails when a command, route or