summaryrefslogtreecommitdiff
path: root/src/cmd
diff options
context:
space:
mode:
Diffstat (limited to 'src/cmd')
-rw-r--r--src/cmd/drop.rs205
-rw-r--r--src/cmd/drop/bundles.rs32
-rw-r--r--src/cmd/drop/bundles/prune.rs113
-rw-r--r--src/cmd/drop/bundles/sync.rs276
-rw-r--r--src/cmd/drop/edit.rs368
-rw-r--r--src/cmd/drop/init.rs194
-rw-r--r--src/cmd/drop/serve.rs140
-rw-r--r--src/cmd/drop/show.rs208
-rw-r--r--src/cmd/drop/snapshot.rs20
-rw-r--r--src/cmd/drop/unbundle.rs93
-rw-r--r--src/cmd/id.rs188
-rw-r--r--src/cmd/id/edit.rs209
-rw-r--r--src/cmd/id/init.rs230
-rw-r--r--src/cmd/id/show.rs75
-rw-r--r--src/cmd/id/sign.rs221
-rw-r--r--src/cmd/mergepoint.rs75
-rw-r--r--src/cmd/patch.rs77
-rw-r--r--src/cmd/patch/create.rs483
-rw-r--r--src/cmd/patch/prepare.rs615
-rw-r--r--src/cmd/topic.rs58
-rw-r--r--src/cmd/topic/comment.rs68
-rw-r--r--src/cmd/topic/ls.rs32
-rw-r--r--src/cmd/topic/show.rs34
-rw-r--r--src/cmd/topic/unbundle.rs174
-rw-r--r--src/cmd/ui.rs131
-rw-r--r--src/cmd/ui/editor.rs228
-rw-r--r--src/cmd/ui/output.rs44
-rw-r--r--src/cmd/util.rs4
-rw-r--r--src/cmd/util/args.rs139
29 files changed, 4734 insertions, 0 deletions
diff --git a/src/cmd/drop.rs b/src/cmd/drop.rs
new file mode 100644
index 0000000..208dbd6
--- /dev/null
+++ b/src/cmd/drop.rs
@@ -0,0 +1,205 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ ops::Deref,
+ path::PathBuf,
+};
+
+use anyhow::{
+ ensure,
+ Context,
+};
+use clap::ValueHint;
+use either::Either::Left;
+
+use crate::{
+ cmd,
+ metadata::{
+ self,
+ git::{
+ FromGit,
+ META_FILE_ALTERNATES,
+ META_FILE_MIRRORS,
+ },
+ IdentityId,
+ Signed,
+ },
+ patches::REF_HEADS_PATCHES,
+};
+
+mod bundles;
+pub use bundles::{
+ sync,
+ Bundles,
+ Sync,
+};
+
+mod edit;
+pub use edit::{
+ edit,
+ Edit,
+};
+
+mod init;
+pub use init::{
+ init,
+ Init,
+};
+
+mod serve;
+pub use serve::{
+ serve,
+ Serve,
+};
+
+mod snapshot;
+pub use snapshot::{
+ snapshot,
+ Snapshot,
+};
+
+mod show;
+pub use show::{
+ show,
+ Show,
+};
+
+mod unbundle;
+pub use unbundle::{
+ unbundle,
+ Unbundle,
+};
+
+#[derive(Debug, clap::Subcommand)]
+#[allow(clippy::large_enum_variant)]
+pub enum Cmd {
+ /// Initialise a drop
+ Init(Init),
+ /// Display the drop metadata
+ Show(Show),
+ /// Serve bundles and patch submission over HTTP
+ Serve(Serve),
+ /// Edit the drop metadata
+ Edit(Edit),
+ /// Manage patch bundles
+ #[clap(subcommand)]
+ Bundles(Bundles),
+ /// Take a snapshot of the patches received so far
+ Snapshot(Snapshot),
+ /// Unbundle the entire drop history
+ Unbundle(Unbundle),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Init(args) => init(args).map(cmd::IntoOutput::into_output),
+ Self::Show(args) => show(args).map(cmd::IntoOutput::into_output),
+ Self::Serve(args) => serve(args).map(cmd::IntoOutput::into_output),
+ Self::Edit(args) => edit(args).map(cmd::IntoOutput::into_output),
+ Self::Bundles(cmd) => cmd.run(),
+ Self::Snapshot(args) => snapshot(args).map(cmd::IntoOutput::into_output),
+ Self::Unbundle(args) => unbundle(args).map(cmd::IntoOutput::into_output),
+ }
+ }
+}
+
+#[derive(Debug, clap::Args)]
+struct Common {
+ /// Path to the drop repository
+ #[clap(from_global)]
+ git_dir: PathBuf,
+ /// A list of paths to search for identity repositories
+ #[clap(
+ long,
+ value_parser,
+ value_name = "PATH",
+ env = "IT_ID_PATH",
+ default_value_t,
+ value_hint = ValueHint::DirPath,
+ )]
+ id_path: cmd::util::args::IdSearchPath,
+}
+
+fn find_id(
+ repo: &git2::Repository,
+ id_path: &[git2::Repository],
+ id: &IdentityId,
+) -> cmd::Result<Signed<metadata::Identity>> {
+ let signed = metadata::Identity::from_search_path(id_path, cmd::id::identity_ref(Left(id))?)?
+ .meta
+ .signed;
+
+ let verified_id = signed
+ .verify(cmd::find_parent(repo))
+ .with_context(|| format!("invalid identity {id}"))?;
+ ensure!(
+ &verified_id == id,
+ "ids do not match after verification: expected {id}, found {verified_id}",
+ );
+
+ Ok(signed)
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+struct Editable {
+ description: metadata::drop::Description,
+ roles: metadata::drop::Roles,
+ custom: metadata::Custom,
+}
+
+impl From<metadata::Drop> for Editable {
+ fn from(
+ metadata::Drop {
+ description,
+ roles,
+ custom,
+ ..
+ }: metadata::Drop,
+ ) -> Self {
+ Self {
+ description,
+ roles,
+ custom,
+ }
+ }
+}
+
+impl TryFrom<Editable> for metadata::Drop {
+ type Error = crate::Error;
+
+ fn try_from(
+ Editable {
+ description,
+ roles,
+ custom,
+ }: Editable,
+ ) -> Result<Self, Self::Error> {
+ ensure!(!roles.root.ids.is_empty(), "drop role cannot be empty");
+ ensure!(
+ !roles.snapshot.ids.is_empty(),
+ "snapshot roles cannot be empty"
+ );
+ ensure!(
+ !roles.branches.is_empty(),
+ "at least one branch role is required"
+ );
+ for (name, ann) in &roles.branches {
+ ensure!(
+ !ann.role.ids.is_empty(),
+ "branch role {name} cannot be empty"
+ );
+ ensure!(name.starts_with("refs/heads/"), "not a branch {name}");
+ ensure!(name.deref() != REF_HEADS_PATCHES, "reserved branch {name}");
+ }
+
+ Ok(Self {
+ spec_version: crate::SPEC_VERSION,
+ description,
+ prev: None,
+ roles,
+ custom,
+ })
+ }
+}
diff --git a/src/cmd/drop/bundles.rs b/src/cmd/drop/bundles.rs
new file mode 100644
index 0000000..7c3e726
--- /dev/null
+++ b/src/cmd/drop/bundles.rs
@@ -0,0 +1,32 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::cmd;
+
+mod prune;
+pub use prune::{
+ prune,
+ Prune,
+};
+
+mod sync;
+pub use sync::{
+ sync,
+ Sync,
+};
+
+#[derive(Debug, clap::Subcommand)]
+#[allow(clippy::large_enum_variant)]
+pub enum Bundles {
+ Sync(Sync),
+ Prune(Prune),
+}
+
+impl Bundles {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Sync(args) => sync(args).map(cmd::IntoOutput::into_output),
+ Self::Prune(args) => prune(args).map(cmd::IntoOutput::into_output),
+ }
+ }
+}
diff --git a/src/cmd/drop/bundles/prune.rs b/src/cmd/drop/bundles/prune.rs
new file mode 100644
index 0000000..6bd984d
--- /dev/null
+++ b/src/cmd/drop/bundles/prune.rs
@@ -0,0 +1,113 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::BTreeSet,
+ fs,
+ path::PathBuf,
+ str::FromStr,
+};
+
+use clap::ValueHint;
+
+use crate::{
+ bundle,
+ cfg,
+ cmd::{
+ self,
+ ui::{
+ info,
+ warn,
+ },
+ },
+ git,
+ patches::iter::dropped,
+};
+
+// TODO:
+//
+// - option to prune bundles made obsolete by snapshots
+
+#[derive(Debug, clap::Args)]
+pub struct Prune {
+ /// Path to the drop repository
+ #[clap(from_global)]
+ git_dir: PathBuf,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = cfg::paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// Name of a git ref holding the drop metadata history
+ ///
+ /// All locally tracked drops should be given, otherwise bundles might get
+ /// pruned which are still being referred to.
+ #[clap(long = "drop", value_parser, value_name = "REF")]
+ drop_refs: Vec<String>,
+ /// Pretend to unlink, but don't
+ #[clap(long, value_parser)]
+ dry_run: bool,
+ /// Also remove location files (.uris)
+ #[clap(long, value_parser)]
+ remove_locations: bool,
+}
+
+pub fn prune(args: Prune) -> cmd::Result<Vec<bundle::Hash>> {
+ let repo = git::repo::open_bare(&args.git_dir)?;
+ let bundle_dir = if args.bundle_dir.is_relative() {
+ repo.path().join(args.bundle_dir)
+ } else {
+ args.bundle_dir
+ };
+
+ let mut seen = BTreeSet::new();
+ for short in &args.drop_refs {
+ let drop_ref = repo.resolve_reference_from_short_name(short)?;
+ let ref_name = drop_ref.name().expect("drop references to be valid utf8");
+ info!("Collecting bundle hashes from {ref_name} ...");
+ for record in dropped::records(&repo, ref_name) {
+ let record = record?;
+ seen.insert(*record.bundle_hash());
+ }
+ }
+
+ info!("Traversing bundle dir {} ...", bundle_dir.display());
+ let mut pruned = Vec::new();
+ for entry in fs::read_dir(&bundle_dir)? {
+ let entry = entry?;
+ let path = entry.path();
+ match path.extension() {
+ Some(ext) if ext == bundle::FILE_EXTENSION => {
+ let name = path.file_stem();
+ match name
+ .and_then(|n| n.to_str())
+ .and_then(|s| bundle::Hash::from_str(s).ok())
+ {
+ Some(hash) => {
+ if !seen.contains(&hash) {
+ if !args.dry_run {
+ fs::remove_file(&path)?;
+ }
+ pruned.push(hash);
+ }
+ },
+ None => warn!("Ignoring {}: file name not a bundle hash", path.display()),
+ }
+ },
+ Some(ext) if ext == bundle::list::FILE_EXTENSION => {
+ if args.remove_locations {
+ fs::remove_file(&path)?;
+ }
+ },
+ _ => warn!("Ignoring {}: missing .bundle", path.display()),
+ }
+ }
+
+ Ok(pruned)
+}
diff --git a/src/cmd/drop/bundles/sync.rs b/src/cmd/drop/bundles/sync.rs
new file mode 100644
index 0000000..21fd58b
--- /dev/null
+++ b/src/cmd/drop/bundles/sync.rs
@@ -0,0 +1,276 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ borrow::Cow,
+ mem,
+ num::NonZeroUsize,
+ path::PathBuf,
+ sync::{
+ Arc,
+ Mutex,
+ },
+ time::{
+ SystemTime,
+ UNIX_EPOCH,
+ },
+};
+
+use anyhow::anyhow;
+use clap::ValueHint;
+use either::Either::{
+ Left,
+ Right,
+};
+use threadpool::ThreadPool;
+use url::Url;
+
+use crate::{
+ bundle,
+ cfg,
+ cmd::{
+ self,
+ drop::Common,
+ ui::{
+ debug,
+ info,
+ warn,
+ },
+ },
+ git::{
+ self,
+ if_not_found_none,
+ },
+ patches::{
+ self,
+ iter::dropped,
+ record,
+ REF_IT_PATCHES,
+ },
+};
+
+/// Max number of locations to store from the remote for which we don't know if
+/// they'd succeed or not.
+pub const MAX_UNTRIED_LOCATIONS: usize = 3;
+
+#[derive(Debug, clap::Args)]
+pub struct Sync {
+ #[clap(flatten)]
+ common: Common,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = cfg::paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// Name of the git ref holding the drop metadata history
+ #[clap(long = "drop", value_parser, value_name = "REF")]
+ drop_ref: Option<String>,
+ /// Base URL to fetch from
+ #[clap(long, value_parser, value_name = "URL", value_hint = ValueHint::Url)]
+ url: Url,
+ /// Fetch via IPFS
+ #[clap(
+ long,
+ value_parser,
+ value_name = "URL",
+ value_hint = ValueHint::Url,
+ env = "IPFS_GATEWAY",
+ default_value_t = Url::parse("https://ipfs.io").unwrap(),
+ )]
+ ipfs_gateway: Url,
+ /// Fetch even if the bundle already exists locally
+ #[clap(long, value_parser)]
+ overwrite: bool,
+ /// Ignore snapshots if encountered
+ #[clap(long, value_parser)]
+ no_snapshots: bool,
+ /// Maximum number of concurrent downloads. Default is the number of
+ /// available cores.
+ #[clap(short, long, value_parser, default_value_t = def_jobs())]
+ jobs: NonZeroUsize,
+}
+
+fn def_jobs() -> NonZeroUsize {
+ NonZeroUsize::new(num_cpus::get()).unwrap_or_else(|| NonZeroUsize::new(1).unwrap())
+}
+
+pub fn sync(args: Sync) -> cmd::Result<Vec<bundle::Info>> {
+ let repo = git::repo::open_bare(&args.common.git_dir)?;
+ let bundle_dir = if args.bundle_dir.is_relative() {
+ repo.path().join(args.bundle_dir)
+ } else {
+ args.bundle_dir
+ };
+ let drop_ref = match args.drop_ref {
+ Some(rev) => if_not_found_none(repo.resolve_reference_from_short_name(&rev))?
+ .ok_or_else(|| anyhow!("no ref matching {rev} found"))?
+ .name()
+ .ok_or_else(|| anyhow!("invalid drop"))?
+ .to_owned(),
+ None => REF_IT_PATCHES.to_owned(),
+ };
+ let base_url = args.url.join("bundles/")?;
+ let fetcher = Arc::new(Fetcher {
+ fetcher: bundle::Fetcher::default(),
+ bundle_dir,
+ base_url: base_url.clone(),
+ ipfs_gateway: args.ipfs_gateway,
+ });
+
+ let pool = ThreadPool::new(args.jobs.get());
+
+ let fetched = Arc::new(Mutex::new(Vec::new()));
+ let mut chasing_snaphots = false;
+ for record in dropped::records(&repo, &drop_ref) {
+ let record = record?;
+ let hexdig = record.bundle_hash().to_string();
+
+ if record.is_snapshot() {
+ if args.no_snapshots {
+ info!("Skipping snapshot bundle {hexdig}");
+ continue;
+ } else {
+ chasing_snaphots = true;
+ }
+ } else if chasing_snaphots && !record.is_mergepoint() {
+ info!("Skipping non-snapshot bundle {hexdig}");
+ continue;
+ }
+
+ if !args.overwrite && record.bundle_path(&fetcher.bundle_dir).exists() {
+ info!("Skipping existing bundle {hexdig}");
+ continue;
+ }
+
+ let record::BundleInfo {
+ info: bundle::Info { len, hash, .. },
+ prerequisites,
+ ..
+ } = record.bundle_info();
+ let url = base_url.join(&hexdig)?;
+
+ pool.execute({
+ let len = *len;
+ let hash = *hash;
+ let fetched = Arc::clone(&fetched);
+ let fetcher = Arc::clone(&fetcher);
+ move || match fetcher.try_fetch(url, len, &hash) {
+ Ok(hash) => fetched.lock().unwrap().push(hash),
+ Err(e) => warn!("Download failed: {e}"),
+ }
+ });
+
+ if record.is_snapshot() && prerequisites.is_empty() {
+ info!("Full snapshot encountered, stopping here");
+ break;
+ }
+ }
+
+ pool.join();
+ let fetched = {
+ let mut guard = fetched.lock().unwrap();
+ mem::take(&mut *guard)
+ };
+
+ Ok(fetched)
+}
+
+struct Fetcher {
+ fetcher: bundle::Fetcher,
+ bundle_dir: PathBuf,
+ base_url: Url,
+ ipfs_gateway: Url,
+}
+
+impl Fetcher {
+ fn try_fetch(&self, url: Url, len: u64, hash: &bundle::Hash) -> cmd::Result<bundle::Info> {
+ info!("Fetching {url} ...");
+
+ let expect = bundle::Expect {
+ len,
+ hash,
+ checksum: None,
+ };
+ let mut locations = Vec::new();
+ let (fetched, origin) = self
+ .fetcher
+ .fetch(&url, &self.bundle_dir, expect)
+ .and_then(|resp| match resp {
+ Right(fetched) => Ok((fetched, url)),
+ Left(lst) => {
+ info!("{url}: response was a bundle list, trying alternate locations");
+
+ let mut iter = lst.bundles.into_iter();
+ let mut found = None;
+
+ for bundle::Location { uri, .. } in &mut iter {
+ if let Some(url) = self.url_from_uri(uri) {
+ if let Ok(Right(info)) =
+ self.fetcher.fetch(&url, &self.bundle_dir, expect)
+ {
+ found = Some((info, url));
+ break;
+ }
+ }
+ }
+
+ // If there are bundle uris left, remember a few
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("backwards system clock")
+ .as_secs();
+ locations.extend(
+ iter
+ // Don't let the remote inflate the priority of
+ // unverified locations
+ .filter(|loc| loc.creation_token.map(|t| t < now).unwrap_or(true))
+ // Only known protocols, relative to base url
+ .filter_map(|loc| {
+ let url = loc.uri.abs(&self.base_url).ok()?;
+ matches!(url.scheme(), "http" | "https" | "ipfs").then(|| {
+ bundle::Location {
+ uri: url.into_owned().into(),
+ ..loc
+ }
+ })
+ })
+ .take(MAX_UNTRIED_LOCATIONS),
+ );
+
+ found.ok_or_else(|| anyhow!("{url}: no reachable location found"))
+ },
+ })?;
+
+ info!("Downloaded {hash} from {origin}");
+ let bundle = patches::Bundle::from_fetched(fetched)?;
+ bundle.write_bundle_list(locations)?;
+
+ Ok(bundle.into())
+ }
+
+ fn url_from_uri(&self, uri: bundle::Uri) -> Option<Url> {
+ uri.abs(&self.base_url)
+ .map_err(Into::into)
+ .and_then(|url: Cow<Url>| -> cmd::Result<Url> {
+ match url.scheme() {
+ "http" | "https" => Ok(url.into_owned()),
+ "ipfs" => {
+ let cid = url
+ .host_str()
+ .ok_or_else(|| anyhow!("{url}: host part not an IPFS CID"))?;
+ let url = self.ipfs_gateway.join(&format!("/ipfs/{cid}"))?;
+ Ok(url)
+ },
+ _ => Err(anyhow!("{url}: unsupported protocol")),
+ }
+ })
+ .map_err(|e| debug!("discarding {}: {}", uri.as_str(), e))
+ .ok()
+ }
+}
diff --git a/src/cmd/drop/edit.rs b/src/cmd/drop/edit.rs
new file mode 100644
index 0000000..9103819
--- /dev/null
+++ b/src/cmd/drop/edit.rs
@@ -0,0 +1,368 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ iter,
+ path::PathBuf,
+};
+
+use anyhow::{
+ anyhow,
+ ensure,
+};
+
+use super::{
+ find_id,
+ Common,
+ Editable,
+};
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ ui::{
+ self,
+ edit_commit_message,
+ edit_metadata,
+ info,
+ },
+ Aborted,
+ },
+ git::{
+ self,
+ refs,
+ Refname,
+ },
+ json,
+ keys::Signer,
+ metadata::{
+ self,
+ git::{
+ FromGit,
+ GitDrop,
+ META_FILE_ALTERNATES,
+ META_FILE_DROP,
+ META_FILE_MIRRORS,
+ },
+ IdentityId,
+ Metadata,
+ },
+ patches::{
+ self,
+ REF_HEADS_PATCHES,
+ REF_IT_PATCHES,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Edit {
+ #[clap(flatten)]
+ common: Common,
+ /// Commit message for this edit
+ ///
+ /// Like git, $EDITOR will be invoked if not specified.
+ #[clap(short, long, value_parser)]
+ message: Option<String>,
+
+ #[clap(subcommand)]
+ cmd: Option<Cmd>,
+}
+
+#[derive(Debug, clap::Subcommand)]
+enum Cmd {
+ /// Edit the mirrors file
+ Mirrors,
+ /// Edit the alternates file
+ Alternates,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ repo: PathBuf,
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn edit(args: Edit) -> cmd::Result<Output> {
+ let Common { git_dir, id_path } = args.common;
+
+ let repo = git::repo::open(git_dir)?;
+ let drop_ref = if repo.is_bare() {
+ REF_HEADS_PATCHES
+ } else {
+ REF_IT_PATCHES
+ }
+ .parse()
+ .unwrap();
+
+ let id_path = id_path.open_git();
+ git::add_alternates(&repo, &id_path)?;
+ let cfg = repo.config()?.snapshot()?;
+ let signer = cfg::signer(&cfg, ui::askpass)?;
+ let signer_id = SignerIdentity::new(&signer, &repo, &cfg, &id_path)?;
+ let meta = metadata::Drop::from_tip(&repo, &drop_ref)?;
+
+ let s = EditState {
+ repo,
+ id_path,
+ signer,
+ signer_id,
+ drop_ref,
+ meta,
+ };
+
+ match args.cmd {
+ None => s.edit_drop(args.message),
+ Some(Cmd::Mirrors) => s.edit_mirrors(args.message),
+ Some(Cmd::Alternates) => s.edit_alternates(args.message),
+ }
+}
+
+struct EditState<S> {
+ repo: git2::Repository,
+ id_path: Vec<git2::Repository>,
+ signer: S,
+ signer_id: SignerIdentity,
+ drop_ref: Refname,
+ meta: GitDrop,
+}
+
+impl<S: Signer + 'static> EditState<S> {
+ fn edit_drop(mut self, message: Option<String>) -> cmd::Result<Output> {
+ let GitDrop {
+ hash: parent_hash,
+ signed: metadata::Signed { signed: parent, .. },
+ } = self.meta;
+
+ ensure!(
+ self.signer_id.can_edit_drop(&parent),
+ "signer identity not allowed to edit the drop metadata"
+ );
+
+ let mut meta: metadata::Drop = edit_metadata(Editable::from(parent.clone()))?.try_into()?;
+ if meta.canonicalise()? == parent.canonicalise()? {
+ info!("Document unchanged");
+ cmd::abort!();
+ }
+ meta.prev = Some(parent_hash);
+
+ let signed = Metadata::drop(&meta).sign(iter::once(&mut self.signer as &mut dyn Signer))?;
+
+ let mut tx = refs::Transaction::new(&self.repo)?;
+ let drop_ref = tx.lock_ref(self.drop_ref)?;
+
+ let parent = self
+ .repo
+ .find_reference(drop_ref.name())?
+ .peel_to_commit()?;
+ let parent_tree = parent.tree()?;
+ let mut root = self.repo.treebuilder(Some(&parent_tree))?;
+ patches::Record::remove_from(&mut root)?;
+
+ let mut ids = self
+ .repo
+ .treebuilder(get_tree(&self.repo, &root, "ids")?.as_ref())?;
+ let identities = meta
+ .roles
+ .ids()
+ .into_iter()
+ .map(|id| find_id(&self.repo, &self.id_path, &id).map(|signed| (id, signed)))
+ .collect::<Result<Vec<_>, _>>()?;
+ for (iid, id) in identities {
+ let iid = iid.to_string();
+ let mut tb = self
+ .repo
+ .treebuilder(get_tree(&self.repo, &ids, &iid)?.as_ref())?;
+ metadata::identity::fold_to_tree(&self.repo, &mut tb, id)?;
+ ids.insert(&iid, tb.write()?, git2::FileMode::Tree.into())?;
+ }
+ root.insert("ids", ids.write()?, git2::FileMode::Tree.into())?;
+
+ root.insert(
+ META_FILE_DROP,
+ json::to_blob(&self.repo, &signed)?,
+ git2::FileMode::Blob.into(),
+ )?;
+ let tree = self.repo.find_tree(root.write()?)?;
+
+ let msg = message.map(Ok).unwrap_or_else(|| {
+ edit_commit_message(&self.repo, drop_ref.name(), &parent_tree, &tree)
+ })?;
+ let commit = git::commit_signed(&mut self.signer, &self.repo, msg, &tree, &[&parent])?;
+ drop_ref.set_target(commit, "it: metadata edit");
+
+ tx.commit()?;
+
+ Ok(Output {
+ repo: self.repo.path().to_owned(),
+ refname: drop_ref.into(),
+ commit,
+ })
+ }
+
+ pub fn edit_mirrors(mut self, message: Option<String>) -> cmd::Result<Output> {
+ ensure!(
+ self.signer_id.can_edit_mirrors(&self.meta.signed.signed),
+ "signer identity not allowed to edit mirrors"
+ );
+
+ let prev = metadata::Mirrors::from_tip(&self.repo, &self.drop_ref)
+ .map(|m| m.signed.signed)
+ .or_else(|e| {
+ if e.is::<metadata::git::error::FileNotFound>() {
+ Ok(Default::default())
+ } else {
+ Err(e)
+ }
+ })?;
+ let prev_canonical = prev.canonicalise()?;
+ let meta = edit_metadata(prev)?;
+ if meta.canonicalise()? == prev_canonical {
+ info!("Document unchanged");
+ cmd::abort!();
+ }
+
+ let signed =
+ Metadata::mirrors(meta).sign(iter::once(&mut self.signer as &mut dyn Signer))?;
+
+ let mut tx = refs::Transaction::new(&self.repo)?;
+ let drop_ref = tx.lock_ref(self.drop_ref)?;
+
+ let parent = self
+ .repo
+ .find_reference(drop_ref.name())?
+ .peel_to_commit()?;
+ let parent_tree = parent.tree()?;
+ let mut root = self.repo.treebuilder(Some(&parent_tree))?;
+ patches::Record::remove_from(&mut root)?;
+ root.insert(
+ META_FILE_MIRRORS,
+ json::to_blob(&self.repo, &signed)?,
+ git2::FileMode::Blob.into(),
+ )?;
+ let tree = self.repo.find_tree(root.write()?)?;
+
+ let msg = message.map(Ok).unwrap_or_else(|| {
+ edit_commit_message(&self.repo, drop_ref.name(), &parent_tree, &tree)
+ })?;
+ let commit = git::commit_signed(&mut self.signer, &self.repo, msg, &tree, &[&parent])?;
+ drop_ref.set_target(commit, "it: mirrors edit");
+
+ tx.commit()?;
+
+ Ok(Output {
+ repo: self.repo.path().to_owned(),
+ refname: drop_ref.into(),
+ commit,
+ })
+ }
+
+ pub fn edit_alternates(mut self, message: Option<String>) -> cmd::Result<Output> {
+ ensure!(
+ self.signer_id.can_edit_mirrors(&self.meta.signed.signed),
+ "signer identity not allowed to edit alternates"
+ );
+
+ let prev = metadata::Alternates::from_tip(&self.repo, &self.drop_ref)
+ .map(|m| m.signed.signed)
+ .or_else(|e| {
+ if e.is::<metadata::git::error::FileNotFound>() {
+ Ok(Default::default())
+ } else {
+ Err(e)
+ }
+ })?;
+ let prev_canonical = prev.canonicalise()?;
+ let meta = edit_metadata(prev)?;
+ if meta.canonicalise()? == prev_canonical {
+ info!("Document unchanged");
+ cmd::abort!();
+ }
+
+ let signed =
+ Metadata::alternates(meta).sign(iter::once(&mut self.signer as &mut dyn Signer))?;
+
+ let mut tx = refs::Transaction::new(&self.repo)?;
+ let drop_ref = tx.lock_ref(self.drop_ref)?;
+
+ let parent = self
+ .repo
+ .find_reference(drop_ref.name())?
+ .peel_to_commit()?;
+ let parent_tree = parent.tree()?;
+ let mut root = self.repo.treebuilder(Some(&parent_tree))?;
+ patches::Record::remove_from(&mut root)?;
+ root.insert(
+ META_FILE_ALTERNATES,
+ json::to_blob(&self.repo, &signed)?,
+ git2::FileMode::Blob.into(),
+ )?;
+ let tree = self.repo.find_tree(root.write()?)?;
+
+ let msg = message.map(Ok).unwrap_or_else(|| {
+ edit_commit_message(&self.repo, drop_ref.name(), &parent_tree, &tree)
+ })?;
+ let commit = git::commit_signed(&mut self.signer, &self.repo, msg, &tree, &[&parent])?;
+ drop_ref.set_target(commit, "it: alternates edit");
+
+ tx.commit()?;
+
+ Ok(Output {
+ repo: self.repo.path().to_owned(),
+ refname: drop_ref.into(),
+ commit,
+ })
+ }
+}
+
+fn get_tree<'a>(
+ repo: &'a git2::Repository,
+ builder: &git2::TreeBuilder,
+ name: &str,
+) -> cmd::Result<Option<git2::Tree<'a>>> {
+ if let Some(entry) = builder.get(name)? {
+ return Ok(Some(
+ entry
+ .to_object(repo)?
+ .into_tree()
+ .map_err(|_| anyhow!("{name} is not a tree"))?,
+ ));
+ }
+
+ Ok(None)
+}
+
+struct SignerIdentity {
+ id: IdentityId,
+}
+
+impl SignerIdentity {
+ pub fn new<S: Signer>(
+ signer: &S,
+ repo: &git2::Repository,
+ cfg: &git2::Config,
+ id_path: &[git2::Repository],
+ ) -> cmd::Result<Self> {
+ let id =
+ cfg::git::identity(cfg)?.ok_or_else(|| anyhow!("signer identity not in gitconfig"))?;
+ let meta = find_id(repo, id_path, &id)?;
+ let keyid = metadata::KeyId::from(signer.ident());
+
+ ensure!(
+ meta.signed.keys.contains_key(&keyid),
+ "signing key {keyid} is not in identity {id}"
+ );
+
+ Ok(Self { id })
+ }
+
+ pub fn can_edit_drop(&self, parent: &metadata::Drop) -> bool {
+ parent.roles.root.ids.contains(&self.id)
+ }
+
+ pub fn can_edit_mirrors(&self, parent: &metadata::Drop) -> bool {
+ parent.roles.mirrors.ids.contains(&self.id)
+ }
+}
diff --git a/src/cmd/drop/init.rs b/src/cmd/drop/init.rs
new file mode 100644
index 0000000..b843255
--- /dev/null
+++ b/src/cmd/drop/init.rs
@@ -0,0 +1,194 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ iter,
+ num::NonZeroUsize,
+ path::PathBuf,
+};
+
+use anyhow::{
+ anyhow,
+ ensure,
+};
+
+use super::{
+ find_id,
+ Common,
+ Editable,
+};
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ ui::{
+ self,
+ edit_metadata,
+ },
+ },
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ },
+ json,
+ metadata::{
+ self,
+ git::META_FILE_DROP,
+ Metadata,
+ },
+ patches::{
+ REF_HEADS_PATCHES,
+ REF_IT_PATCHES,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Init {
+ #[clap(flatten)]
+ common: Common,
+ /// A description for this drop instance, max. 128 characters
+ #[clap(long, value_parser, value_name = "STRING")]
+ description: metadata::drop::Description,
+ /// If the repository does not already exist, initialise it as non-bare
+ ///
+ /// A drop is usually initialised inside an already existing git repository,
+ /// or as a standalone drop repository. The latter is advisable for serving
+ /// over the network.
+ ///
+ /// When init is given a directory which does not already exist, it is
+ /// assumed that a standalone drop should be created, and thus the
+ /// repository is initialised as bare. This behaviour can be overridden
+ /// by --no-bare.
+ #[clap(long, value_parser)]
+ no_bare: bool,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ repo: PathBuf,
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn init(args: Init) -> cmd::Result<Output> {
+ let Common { git_dir, id_path } = args.common;
+ let drop_ref: Refname = REF_IT_PATCHES.parse().unwrap();
+
+ let repo = git::repo::open_or_init(
+ git_dir,
+ git::repo::InitOpts {
+ bare: !args.no_bare,
+ description: "`it` drop",
+ initial_head: &drop_ref,
+ },
+ )?;
+
+ let mut tx = refs::Transaction::new(&repo)?;
+ let drop_ref = tx.lock_ref(drop_ref)?;
+ ensure!(
+ if_not_found_none(repo.refname_to_id(drop_ref.name()))?.is_none(),
+ "{} already exists",
+ drop_ref
+ );
+
+ let id_path = id_path.open_git();
+ git::add_alternates(&repo, &id_path)?;
+
+ let cfg = repo.config()?.snapshot()?;
+ let mut signer = cfg::signer(&cfg, ui::askpass)?;
+ let signer_id = {
+ let iid =
+ cfg::git::identity(&cfg)?.ok_or_else(|| anyhow!("signer identity not in gitconfig"))?;
+ let id = find_id(&repo, &id_path, &iid)?;
+ let keyid = metadata::KeyId::from(signer.ident());
+ ensure!(
+ id.signed.keys.contains_key(&keyid),
+ "signing key {keyid} is not in identity {iid}"
+ );
+
+ iid
+ };
+
+ let default = {
+ let default_role = metadata::drop::Role {
+ ids: [signer_id].into(),
+ threshold: NonZeroUsize::new(1).unwrap(),
+ };
+ let default_branch = cfg::git::default_branch(&cfg)?;
+
+ metadata::Drop {
+ spec_version: crate::SPEC_VERSION,
+ description: args.description,
+ prev: None,
+ custom: Default::default(),
+ roles: metadata::drop::Roles {
+ root: default_role.clone(),
+ snapshot: default_role.clone(),
+ mirrors: default_role.clone(),
+ branches: [(
+ default_branch,
+ metadata::drop::Annotated {
+ role: default_role,
+ description: metadata::drop::Description::try_from(
+ "the default branch".to_owned(),
+ )
+ .unwrap(),
+ },
+ )]
+ .into(),
+ },
+ }
+ };
+ let meta: metadata::Drop = edit_metadata(Editable::from(default))?.try_into()?;
+ ensure!(
+ meta.roles.root.ids.contains(&signer_id),
+ "signing identity {signer_id} is lacking the drop role required to sign the metadata"
+ );
+ let signed = Metadata::drop(&meta).sign(iter::once(&mut signer))?;
+
+ let mut root = repo.treebuilder(None)?;
+ let mut ids = repo.treebuilder(None)?;
+ let identities = meta
+ .roles
+ .ids()
+ .into_iter()
+ .map(|id| find_id(&repo, &id_path, &id).map(|signed| (id, signed)))
+ .collect::<Result<Vec<_>, _>>()?;
+ for (iid, id) in identities {
+ let iid = iid.to_string();
+ let mut tb = repo.treebuilder(None)?;
+ metadata::identity::fold_to_tree(&repo, &mut tb, id)?;
+ ids.insert(&iid, tb.write()?, git2::FileMode::Tree.into())?;
+ }
+ root.insert("ids", ids.write()?, git2::FileMode::Tree.into())?;
+ root.insert(
+ META_FILE_DROP,
+ json::to_blob(&repo, &signed)?,
+ git2::FileMode::Blob.into(),
+ )?;
+ let tree = repo.find_tree(root.write()?)?;
+ let msg = format!("Create drop '{}'", meta.description);
+ let commit = git::commit_signed(&mut signer, &repo, msg, &tree, &[])?;
+
+ if repo.is_bare() {
+ // Arrange refs to be `git-clone`-friendly
+ let heads_patches = tx.lock_ref(REF_HEADS_PATCHES.parse()?)?;
+ heads_patches.set_target(commit, "it: create");
+ drop_ref.set_symbolic_target(heads_patches.name().clone(), String::new());
+ repo.set_head(heads_patches.name())?;
+ } else {
+ drop_ref.set_target(commit, "it: create");
+ }
+
+ tx.commit()?;
+
+ Ok(Output {
+ repo: repo.path().to_owned(),
+ refname: drop_ref.into(),
+ commit,
+ })
+}
diff --git a/src/cmd/drop/serve.rs b/src/cmd/drop/serve.rs
new file mode 100644
index 0000000..7540d58
--- /dev/null
+++ b/src/cmd/drop/serve.rs
@@ -0,0 +1,140 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ fs::File,
+ io::Read,
+ path::PathBuf,
+ str::FromStr,
+};
+
+use clap::ValueHint;
+use url::Url;
+
+use super::Common;
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ },
+ http,
+ patches::{
+ REF_IT_BUNDLES,
+ REF_IT_PATCHES,
+ REF_IT_SEEN,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Serve {
+ #[clap(flatten)]
+ common: Common,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = cfg::paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// Ref prefix under which to store the refs contained in patch bundles
+ #[clap(
+ long,
+ value_parser,
+ value_name = "REF",
+ default_value_t = Refname::from_str(REF_IT_BUNDLES).unwrap()
+ )]
+ unbundle_prefix: Refname,
+ /// The refname anchoring the seen objects tree
+ #[clap(
+ long,
+ value_parser,
+ value_name = "REF",
+ default_value_t = Refname::from_str(REF_IT_SEEN).unwrap()
+ )]
+ seen_ref: Refname,
+ /// 'host:port' to listen on
+ #[clap(
+ long,
+ value_parser,
+ value_name = "HOST:PORT",
+ default_value = "127.0.0.1:8084"
+ )]
+ listen: String,
+ /// Number of threads to use for the server
+ ///
+ /// If not set, the number of available cores is used.
+ #[clap(long, value_parser, value_name = "INT")]
+ threads: Option<usize>,
+ /// PEM-encoded TLS certificate
+ ///
+ /// Requires 'tls-key'. If not set (the default), the server will not use
+ /// TLS.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "FILE",
+ requires = "tls_key",
+ value_hint = ValueHint::FilePath
+ )]
+ tls_cert: Option<PathBuf>,
+ /// PEM-encoded TLS private key
+ ///
+ /// Requires 'tls-cert'. If not set (the default), the server will not use
+ /// TLS.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "FILE",
+ requires = "tls_cert",
+ value_hint = ValueHint::FilePath
+ )]
+ tls_key: Option<PathBuf>,
+ /// IPFS API to publish received patch bundle to
+ #[clap(
+ long,
+ value_parser,
+ value_name = "URL",
+ value_hint = ValueHint::Url,
+ )]
+ ipfs_api: Option<Url>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output;
+
+pub fn serve(args: Serve) -> cmd::Result<Output> {
+ let tls = args
+ .tls_cert
+ .map(|cert_path| -> cmd::Result<http::SslConfig> {
+ let mut certificate = Vec::new();
+ let mut private_key = Vec::new();
+ File::open(cert_path)?.read_to_end(&mut certificate)?;
+ File::open(args.tls_key.expect("presence of 'tls-key' ensured by clap"))?
+ .read_to_end(&mut private_key)?;
+
+ Ok(http::SslConfig {
+ certificate,
+ private_key,
+ })
+ })
+ .transpose()?;
+
+ http::serve(
+ args.listen,
+ http::Options {
+ git_dir: args.common.git_dir,
+ bundle_dir: args.bundle_dir,
+ unbundle_prefix: args.unbundle_prefix.into(),
+ drop_ref: REF_IT_PATCHES.into(),
+ seen_ref: args.seen_ref.into(),
+ threads: args.threads,
+ tls,
+ ipfs_api: args.ipfs_api,
+ },
+ )
+}
diff --git a/src/cmd/drop/show.rs b/src/cmd/drop/show.rs
new file mode 100644
index 0000000..e3fdcfc
--- /dev/null
+++ b/src/cmd/drop/show.rs
@@ -0,0 +1,208 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::BTreeMap,
+ io,
+ path::PathBuf,
+};
+
+use anyhow::Context;
+
+use super::{
+ Common,
+ META_FILE_ALTERNATES,
+ META_FILE_MIRRORS,
+};
+use crate::{
+ cmd::{
+ self,
+ util::args::Refname,
+ FromGit as _,
+ GitAlternates,
+ GitDrop,
+ GitMirrors,
+ },
+ git,
+ metadata::{
+ self,
+ ContentHash,
+ IdentityId,
+ KeySet,
+ },
+ patches::REF_IT_PATCHES,
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Show {
+ #[clap(flatten)]
+ common: Common,
+ /// Name of the git ref holding the drop metadata history
+ #[clap(
+ long = "drop",
+ value_parser,
+ value_name = "REF",
+ default_value_t = REF_IT_PATCHES.parse().unwrap(),
+ )]
+ drop_ref: Refname,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ repo: PathBuf,
+ refname: Refname,
+ drop: Data<metadata::Drop>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ mirrors: Option<Data<metadata::Mirrors>>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ alternates: Option<Data<metadata::Alternates>>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Data<T> {
+ hash: ContentHash,
+ status: Status,
+ json: T,
+}
+
+#[derive(serde::Serialize)]
+#[serde(rename_all = "UPPERCASE")]
+pub enum Status {
+ Verified,
+ #[serde(with = "crate::serde::display")]
+ Invalid(metadata::error::Verification),
+}
+
+impl From<Result<(), metadata::error::Verification>> for Status {
+ fn from(r: Result<(), metadata::error::Verification>) -> Self {
+ r.map(|()| Self::Verified).unwrap_or_else(Self::Invalid)
+ }
+}
+
+pub fn show(args: Show) -> cmd::Result<Output> {
+ let Common { git_dir, .. } = args.common;
+ let drop_ref = args.drop_ref;
+
+ let repo = git::repo::open(git_dir)?;
+
+ let GitDrop {
+ hash,
+ signed: metadata::Signed {
+ signed: drop,
+ signatures,
+ },
+ } = metadata::Drop::from_tip(&repo, &drop_ref)?;
+
+ let mut signer_cache = SignerCache::new(&repo, &drop_ref)?;
+ let status = drop
+ .verify(
+ &signatures,
+ cmd::find_parent(&repo),
+ find_signer(&mut signer_cache),
+ )
+ .into();
+
+ let mut mirrors = None;
+ let mut alternates = None;
+
+ let tree = repo.find_reference(&drop_ref)?.peel_to_commit()?.tree()?;
+ if let Some(entry) = tree.get_name(META_FILE_MIRRORS) {
+ let blob = entry.to_object(&repo)?.peel_to_blob()?;
+ let GitMirrors { hash, signed } = metadata::Mirrors::from_blob(&blob)?;
+ let status = drop
+ .verify_mirrors(&signed, find_signer(&mut signer_cache))
+ .into();
+
+ mirrors = Some(Data {
+ hash,
+ status,
+ json: signed.signed,
+ });
+ }
+
+ if let Some(entry) = tree.get_name(META_FILE_ALTERNATES) {
+ let blob = entry.to_object(&repo)?.peel_to_blob()?;
+ let GitAlternates { hash, signed } = metadata::Alternates::from_blob(&blob)?;
+ let status = drop
+ .verify_alternates(&signed, find_signer(&mut signer_cache))
+ .into();
+
+ alternates = Some(Data {
+ hash,
+ status,
+ json: signed.signed,
+ });
+ }
+
+ Ok(Output {
+ repo: repo.path().to_owned(),
+ refname: drop_ref,
+ drop: Data {
+ hash,
+ status,
+ json: drop,
+ },
+ mirrors,
+ alternates,
+ })
+}
+
+struct SignerCache<'a> {
+ repo: &'a git2::Repository,
+ root: git2::Tree<'a>,
+ keys: BTreeMap<IdentityId, KeySet<'static>>,
+}
+
+impl<'a> SignerCache<'a> {
+ pub(self) fn new(repo: &'a git2::Repository, refname: &Refname) -> git::Result<Self> {
+ let root = {
+ let id = repo
+ .find_reference(refname)?
+ .peel_to_tree()?
+ .get_name("ids")
+ .ok_or_else(|| {
+ git2::Error::new(
+ git2::ErrorCode::NotFound,
+ git2::ErrorClass::Tree,
+ "'ids' tree not found",
+ )
+ })?
+ .id();
+ repo.find_tree(id)?
+ };
+ let keys = BTreeMap::new();
+
+ Ok(Self { repo, root, keys })
+ }
+}
+
+fn find_signer<'a>(
+ cache: &'a mut SignerCache,
+) -> impl FnMut(&IdentityId) -> io::Result<KeySet<'static>> + 'a {
+ fn go(
+ repo: &git2::Repository,
+ root: &git2::Tree,
+ keys: &mut BTreeMap<IdentityId, KeySet<'static>>,
+ id: &IdentityId,
+ ) -> cmd::Result<KeySet<'static>> {
+ match keys.get(id) {
+ Some(keys) => Ok(keys.clone()),
+ None => {
+ let (id, verified) = metadata::identity::find_in_tree(repo, root, id)
+ .with_context(|| format!("identity {id} failed to verify"))?
+ .into_parts();
+ keys.insert(id, verified.keys.clone());
+ Ok(verified.keys)
+ },
+ }
+ }
+
+ |id| go(cache.repo, &cache.root, &mut cache.keys, id).map_err(as_io)
+}
+
+fn as_io<E>(e: E) -> io::Error
+where
+ E: Into<Box<dyn std::error::Error + Send + Sync>>,
+{
+ io::Error::new(io::ErrorKind::Other, e)
+}
diff --git a/src/cmd/drop/snapshot.rs b/src/cmd/drop/snapshot.rs
new file mode 100644
index 0000000..b1348d3
--- /dev/null
+++ b/src/cmd/drop/snapshot.rs
@@ -0,0 +1,20 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::{
+ cmd::{
+ self,
+ patch,
+ },
+ patches,
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Snapshot {
+ #[clap(flatten)]
+ common: patch::Common,
+}
+
+pub fn snapshot(Snapshot { common }: Snapshot) -> cmd::Result<patches::Record> {
+ patch::create(patch::Kind::Snapshot { common })
+}
diff --git a/src/cmd/drop/unbundle.rs b/src/cmd/drop/unbundle.rs
new file mode 100644
index 0000000..a9c9f77
--- /dev/null
+++ b/src/cmd/drop/unbundle.rs
@@ -0,0 +1,93 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::BTreeMap,
+ path::PathBuf,
+};
+
+use anyhow::anyhow;
+use clap::ValueHint;
+
+use crate::{
+ cmd,
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ Refname,
+ },
+ patches::{
+ self,
+ iter::dropped,
+ Bundle,
+ REF_IT_BUNDLES,
+ REF_IT_PATCHES,
+ },
+ paths,
+};
+
+// TODO:
+//
+// - require drop metadata verification
+// - abort if existing ref would be set to a different target (or --force)
+// - honour snapshots
+//
+
+#[derive(Debug, clap::Args)]
+pub struct Unbundle {
+ #[clap(from_global)]
+ git_dir: PathBuf,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// The drop history to find the topic in
+ #[clap(value_parser)]
+ drop: Option<String>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ updated: BTreeMap<Refname, git::serde::oid::Oid>,
+}
+
+pub fn unbundle(args: Unbundle) -> cmd::Result<Output> {
+ let repo = git::repo::open(&args.git_dir)?;
+ let bundle_dir = if args.bundle_dir.is_relative() {
+ repo.path().join(args.bundle_dir)
+ } else {
+ args.bundle_dir
+ };
+ let drop = match args.drop {
+ Some(rev) => if_not_found_none(repo.resolve_reference_from_short_name(&rev))?
+ .ok_or_else(|| anyhow!("no ref matching {rev} found"))?
+ .name()
+ .ok_or_else(|| anyhow!("invalid drop"))?
+ .to_owned(),
+ None => REF_IT_PATCHES.to_owned(),
+ };
+
+ let odb = repo.odb()?;
+ let mut tx = refs::Transaction::new(&repo)?;
+ let mut up = BTreeMap::new();
+ for rec in dropped::records_rev(&repo, &drop) {
+ let rec = rec?;
+ let bundle = Bundle::from_stored(&bundle_dir, rec.bundle_info().as_expect())?;
+ bundle.packdata()?.index(&odb)?;
+ let updated = patches::unbundle(&odb, &mut tx, REF_IT_BUNDLES, &rec)?;
+ for (name, oid) in updated {
+ up.insert(name, oid.into());
+ }
+ }
+ tx.commit()?;
+
+ Ok(Output { updated: up })
+}
diff --git a/src/cmd/id.rs b/src/cmd/id.rs
new file mode 100644
index 0000000..7504489
--- /dev/null
+++ b/src/cmd/id.rs
@@ -0,0 +1,188 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::BTreeSet,
+ num::NonZeroUsize,
+ path::PathBuf,
+};
+
+use anyhow::{
+ anyhow,
+ ensure,
+};
+use clap::ValueHint;
+use either::{
+ Either,
+ Left,
+ Right,
+};
+use url::Url;
+
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ },
+ git,
+ metadata::{
+ self,
+ git::META_FILE_ID,
+ IdentityId,
+ },
+ paths,
+};
+
+mod edit;
+pub use edit::{
+ edit,
+ Edit,
+};
+
+mod init;
+pub use init::{
+ init,
+ Init,
+};
+
+mod show;
+pub use show::{
+ show,
+ Show,
+};
+
+mod sign;
+pub use sign::{
+ sign,
+ Sign,
+};
+
+#[derive(Debug, clap::Subcommand)]
+#[allow(clippy::large_enum_variant)]
+pub enum Cmd {
+ /// Initialise a fresh identity
+ Init(Init),
+ /// Display the identity docment
+ Show(Show),
+ /// Edit the identity document
+ Edit(Edit),
+ /// Sign a proposed identity document
+ Sign(Sign),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Init(args) => init(args).map(cmd::IntoOutput::into_output),
+ Self::Show(args) => show(args).map(cmd::IntoOutput::into_output),
+ Self::Edit(args) => edit(args).map(cmd::IntoOutput::into_output),
+ Self::Sign(args) => sign(args).map(cmd::IntoOutput::into_output),
+ }
+ }
+}
+
+#[derive(Clone, Debug, clap::Args)]
+pub struct Common {
+ /// Path to the 'keyring' repository
+ // nb. not using from_global here -- current_dir doesn't make sense here as
+ // the default
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ env = "GIT_DIR",
+ default_value_os_t = paths::ids(),
+ value_hint = ValueHint::DirPath,
+ )]
+ git_dir: PathBuf,
+ /// Identity to operate on
+ ///
+ /// If not set as an option nor in the environment, the value of `it.id` in
+ /// the git config is tried.
+ #[clap(short = 'I', long = "identity", value_name = "ID", env = "IT_ID")]
+ id: Option<IdentityId>,
+}
+
+impl Common {
+ pub fn resolve(&self) -> cmd::Result<(git2::Repository, Refname)> {
+ let repo = git::repo::open(&self.git_dir)?;
+ let refname = identity_ref(
+ match self.id {
+ Some(id) => Left(id),
+ None => Right(repo.config()?),
+ }
+ .as_ref(),
+ )?;
+
+ Ok((repo, refname))
+ }
+}
+
+pub fn identity_ref(id: Either<&IdentityId, &git2::Config>) -> cmd::Result<Refname> {
+ let id = id.either(
+ |iid| Ok(iid.to_string()),
+ |cfg| {
+ cfg::git::identity(cfg)?
+ .ok_or_else(|| anyhow!("'{}' not set", cfg::git::IT_ID))
+ .map(|iid| iid.to_string())
+ },
+ )?;
+ Ok(Refname::try_from(format!("refs/heads/it/ids/{id}"))?)
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+struct Editable {
+ keys: metadata::KeySet<'static>,
+ threshold: NonZeroUsize,
+ mirrors: BTreeSet<Url>,
+ expires: Option<metadata::DateTime>,
+ custom: metadata::Custom,
+}
+
+impl From<metadata::Identity> for Editable {
+ fn from(
+ metadata::Identity {
+ keys,
+ threshold,
+ mirrors,
+ expires,
+ custom,
+ ..
+ }: metadata::Identity,
+ ) -> Self {
+ Self {
+ keys,
+ threshold,
+ mirrors,
+ expires,
+ custom,
+ }
+ }
+}
+
+impl TryFrom<Editable> for metadata::Identity {
+ type Error = crate::Error;
+
+ fn try_from(
+ Editable {
+ keys,
+ threshold,
+ mirrors,
+ expires,
+ custom,
+ }: Editable,
+ ) -> Result<Self, Self::Error> {
+ ensure!(!keys.is_empty(), "keys cannot be empty");
+
+ Ok(Self {
+ spec_version: crate::SPEC_VERSION,
+ prev: None,
+ keys,
+ threshold,
+ mirrors,
+ expires,
+ custom,
+ })
+ }
+}
diff --git a/src/cmd/id/edit.rs b/src/cmd/id/edit.rs
new file mode 100644
index 0000000..02687b8
--- /dev/null
+++ b/src/cmd/id/edit.rs
@@ -0,0 +1,209 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ fs::File,
+ iter,
+ path::Path,
+};
+
+use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+ Context,
+};
+
+use super::{
+ Common,
+ Editable,
+ META_FILE_ID,
+};
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ ui::{
+ self,
+ edit_commit_message,
+ edit_metadata,
+ info,
+ warn,
+ },
+ Aborted,
+ FromGit as _,
+ GitIdentity,
+ },
+ git::{
+ self,
+ refs,
+ },
+ json,
+ metadata::{
+ self,
+ Metadata,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+#[allow(rustdoc::bare_urls)]
+pub struct Edit {
+ #[clap(flatten)]
+ common: Common,
+ /// Commit to this branch to propose the update
+ ///
+ /// If not given, the edit is performed in-place if the signature threshold
+ /// is met using the supplied keys.
+ #[clap(long, value_parser)]
+ propose_as: Option<Refname>,
+ /// Check out the committed changes
+ ///
+ /// Only has an effect if the repository is non-bare.
+ #[clap(long, value_parser)]
+ checkout: bool,
+ /// Don't commit anything to disk
+ #[clap(long, value_parser)]
+ dry_run: bool,
+ /// Commit message for this edit
+ ///
+ /// Like git, $EDITOR will be invoked if not specified.
+ #[clap(short, long, value_parser)]
+ message: Option<String>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn edit(args: Edit) -> cmd::Result<Output> {
+ let (repo, refname) = args.common.resolve()?;
+
+ let GitIdentity {
+ hash: parent_hash,
+ signed: metadata::Signed { signed: parent, .. },
+ } = metadata::Identity::from_tip(&repo, &refname)?;
+
+ let mut id: metadata::Identity = edit_metadata(Editable::from(parent.clone()))?.try_into()?;
+ if id.canonicalise()? == parent.canonicalise()? {
+ info!("Document unchanged");
+ cmd::abort!();
+ }
+ id.prev = Some(parent_hash.clone());
+
+ let cfg = repo.config()?;
+ let mut signer = cfg::signer(&cfg, ui::askpass)?;
+ let keyid = metadata::KeyId::from(signer.ident());
+ ensure!(
+ parent.keys.contains_key(&keyid) || id.keys.contains_key(&keyid),
+ "signing key {keyid} is not eligible to sign the document"
+ );
+ let signed = Metadata::identity(&id).sign(iter::once(&mut signer))?;
+
+ let commit_to = match id.verify(&signed.signatures, cmd::find_parent(&repo)) {
+ Ok(_) => args.propose_as.as_ref().unwrap_or(&refname),
+ Err(metadata::error::Verification::SignatureThreshold) => match &args.propose_as {
+ None => bail!("cannot update {refname} in place as signature threshold is not met"),
+ Some(tgt) => {
+ warn!("Signature threshold is not met");
+ tgt
+ },
+ },
+ Err(e) => bail!(e),
+ };
+
+ let mut tx = refs::Transaction::new(&repo)?;
+
+ let _tip = tx.lock_ref(refname.clone())?;
+ let tip = repo.find_reference(_tip.name())?;
+ let parent_commit = tip.peel_to_commit()?;
+ let parent_tree = parent_commit.tree()?;
+ // check that parent is valid
+ {
+ let entry = parent_tree.get_name(META_FILE_ID).ok_or_else(|| {
+ anyhow!("{refname} was modified concurrently, {META_FILE_ID} not found in tree")
+ })?;
+ ensure!(
+ parent_hash == entry.to_object(&repo)?.peel_to_blob()?.id(),
+ "{refname} was modified concurrently",
+ );
+ }
+ let commit_to = tx.lock_ref(commit_to.clone())?;
+ let on_head =
+ !repo.is_bare() && git2::Branch::wrap(repo.find_reference(commit_to.name())?).is_head();
+
+ let tree = if on_head {
+ write_tree(&repo, &signed)
+ } else {
+ write_tree_bare(&repo, &signed, Some(&parent_tree))
+ }?;
+ let msg = args
+ .message
+ .map(Ok)
+ .unwrap_or_else(|| edit_commit_message(&repo, commit_to.name(), &parent_tree, &tree))?;
+ let commit = git::commit_signed(&mut signer, &repo, msg, &tree, &[&parent_commit])?;
+ commit_to.set_target(commit, "it: edit identity");
+
+ tx.commit()?;
+
+ if args.checkout && repo.is_bare() {
+ bail!("repository is bare, refusing checkout");
+ }
+ if args.checkout || on_head {
+ repo.checkout_tree(
+ tree.as_object(),
+ Some(git2::build::CheckoutBuilder::new().safe()),
+ )?;
+ repo.set_head(commit_to.name())?;
+ info!("Switched to branch '{commit_to}'");
+ }
+
+ Ok(Output {
+ refname: commit_to.into(),
+ commit,
+ })
+}
+
+pub(super) fn write_tree<'a>(
+ repo: &'a git2::Repository,
+ meta: &metadata::Signed<metadata::Metadata>,
+) -> crate::Result<git2::Tree<'a>> {
+ ensure!(
+ repo.statuses(None)?.is_empty(),
+ "uncommitted changes in working tree. Please commit or stash them before proceeding"
+ );
+ let id_json = repo
+ .workdir()
+ .expect("non-bare repo ought to have a workdir")
+ .join(META_FILE_ID);
+ let out = File::options()
+ .write(true)
+ .truncate(true)
+ .open(&id_json)
+ .with_context(|| format!("error opening {} for writing", id_json.display()))?;
+ serde_json::to_writer_pretty(&out, meta)
+ .with_context(|| format!("serialising to {} failed", id_json.display()))?;
+
+ let mut index = repo.index()?;
+ index.add_path(Path::new(META_FILE_ID))?;
+ let oid = index.write_tree()?;
+
+ Ok(repo.find_tree(oid)?)
+}
+
+pub(super) fn write_tree_bare<'a>(
+ repo: &'a git2::Repository,
+ meta: &metadata::Signed<metadata::Metadata>,
+ from: Option<&git2::Tree>,
+) -> crate::Result<git2::Tree<'a>> {
+ let blob = json::to_blob(repo, meta)?;
+ let mut bld = repo.treebuilder(from)?;
+ bld.insert(META_FILE_ID, blob, git2::FileMode::Blob.into())?;
+ let oid = bld.write()?;
+
+ Ok(repo.find_tree(oid)?)
+}
diff --git a/src/cmd/id/init.rs b/src/cmd/id/init.rs
new file mode 100644
index 0000000..a0ed119
--- /dev/null
+++ b/src/cmd/id/init.rs
@@ -0,0 +1,230 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::{
+ iter,
+ num::NonZeroUsize,
+};
+use std::path::PathBuf;
+
+use anyhow::ensure;
+use clap::ValueHint;
+use url::Url;
+
+use super::{
+ Editable,
+ META_FILE_ID,
+};
+use crate::{
+ cfg::{
+ self,
+ paths,
+ },
+ cmd::{
+ self,
+ args::Refname,
+ ui::{
+ self,
+ edit_metadata,
+ info,
+ },
+ },
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ },
+ json,
+ metadata::{
+ self,
+ DateTime,
+ Key,
+ KeySet,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Init {
+ /// Path to the 'keyring' repository
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ env = "GIT_DIR",
+ default_value_os_t = paths::ids(),
+ value_hint = ValueHint::DirPath,
+ )]
+ git_dir: PathBuf,
+ /// If the repository does not already exist, initialise it as non-bare
+ ///
+ /// Having the identity files checked out into a work tree may make it
+ /// easier to manipulate them with external tooling. Note, however, that
+ /// only committed files are considered by `it`.
+ #[clap(long, value_parser)]
+ no_bare: bool,
+ /// Set this identity as the default in the user git config
+ #[clap(long, value_parser)]
+ set_default: bool,
+ /// Additional public key to add to the identity; may be given multiple
+ /// times
+ #[clap(short, long, value_parser)]
+ public: Vec<Key<'static>>,
+ /// Threshold of keys required to sign the next revision
+ #[clap(long, value_parser)]
+ threshold: Option<NonZeroUsize>,
+ /// Alternate location where the identity history is published to; may be
+ /// given multiple times
+ #[clap(
+ long = "mirror",
+ value_parser,
+ value_name = "URL",
+ value_hint = ValueHint::Url,
+ )]
+ mirrors: Vec<Url>,
+ /// Optional date/time after which the current revision of the identity
+ /// should no longer be considered valid
+ #[clap(long, value_parser, value_name = "DATETIME")]
+ expires: Option<DateTime>,
+ /// Custom data
+ ///
+ /// The data must be parseable as canonical JSON, ie. not contain any
+ /// floating point values.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "FILE",
+ value_hint = ValueHint::FilePath,
+ )]
+ custom: Option<PathBuf>,
+ /// Stop for editing the metadata in $EDITOR
+ #[clap(long, value_parser)]
+ edit: bool,
+ /// Don't commit anything to disk
+ #[clap(long, value_parser)]
+ dry_run: bool,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ committed: Option<Committed>,
+ data: metadata::Signed<metadata::Metadata<'static>>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Committed {
+ repo: PathBuf,
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn init(args: Init) -> cmd::Result<Output> {
+ let git_dir = args.git_dir;
+ info!("Initialising fresh identity at {}", git_dir.display());
+
+ let custom = args.custom.map(json::load).transpose()?.unwrap_or_default();
+ let cfg = git2::Config::open_default()?;
+ let mut signer = cfg::signer(&cfg, ui::askpass)?;
+ let threshold = match args.threshold {
+ None => NonZeroUsize::new(1)
+ .unwrap()
+ .saturating_add(args.public.len() / 2),
+ Some(t) => {
+ ensure!(
+ t.get() < args.public.len(),
+ "threshold must be smaller than the number of keys"
+ );
+ t
+ },
+ };
+
+ let signer_id = signer.ident().to_owned();
+ let keys = iter::once(signer_id.clone())
+ .map(metadata::Key::from)
+ .chain(args.public)
+ .collect::<KeySet>();
+
+ let meta = {
+ let id = metadata::Identity {
+ spec_version: crate::SPEC_VERSION,
+ prev: None,
+ keys,
+ threshold,
+ mirrors: args.mirrors.into_iter().collect(),
+ expires: args.expires,
+ custom,
+ };
+
+ if args.edit {
+ edit_metadata(Editable::from(id))?.try_into()?
+ } else {
+ id
+ }
+ };
+ let sigid = metadata::IdentityId::try_from(&meta).unwrap();
+ let signed = metadata::Metadata::identity(meta).sign(iter::once(&mut signer))?;
+
+ let out = if !args.dry_run {
+ let id_ref = Refname::try_from(format!("refs/heads/it/ids/{}", sigid)).unwrap();
+ let repo = git::repo::open_or_init(
+ git_dir,
+ git::repo::InitOpts {
+ bare: !args.no_bare,
+ description: "`it` keyring",
+ initial_head: &id_ref,
+ },
+ )?;
+
+ let mut tx = refs::Transaction::new(&repo)?;
+ let id_ref = tx.lock_ref(id_ref)?;
+ ensure!(
+ if_not_found_none(repo.refname_to_id(id_ref.name()))?.is_none(),
+ "{id_ref} already exists",
+ );
+
+ let blob = json::to_blob(&repo, &signed)?;
+ let tree = {
+ let mut bld = repo.treebuilder(None)?;
+ bld.insert(META_FILE_ID, blob, git2::FileMode::Blob.into())?;
+ let oid = bld.write()?;
+ repo.find_tree(oid)?
+ };
+ let msg = format!("Create identity {}", sigid);
+ let oid = git::commit_signed(&mut signer, &repo, msg, &tree, &[])?;
+ id_ref.set_target(oid, "it: create");
+
+ let mut cfg = repo.config()?;
+ cfg.set_str(
+ cfg::git::USER_SIGNING_KEY,
+ &format!("key::{}", signer_id.to_openssh()?),
+ )?;
+ let idstr = sigid.to_string();
+ cfg.set_str(cfg::git::IT_ID, &idstr)?;
+ if args.set_default {
+ cfg.open_global()?.set_str(cfg::git::IT_ID, &idstr)?;
+ }
+
+ tx.commit()?;
+ if !repo.is_bare() {
+ repo.checkout_head(None).ok();
+ }
+
+ Output {
+ committed: Some(Committed {
+ repo: repo.path().to_owned(),
+ refname: id_ref.into(),
+ commit: oid,
+ }),
+ data: signed,
+ }
+ } else {
+ Output {
+ committed: None,
+ data: signed,
+ }
+ };
+
+ Ok(out)
+}
diff --git a/src/cmd/id/show.rs b/src/cmd/id/show.rs
new file mode 100644
index 0000000..4a25455
--- /dev/null
+++ b/src/cmd/id/show.rs
@@ -0,0 +1,75 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::path::PathBuf;
+
+use super::Common;
+use crate::{
+ cmd::{
+ self,
+ args::Refname,
+ FromGit as _,
+ GitIdentity,
+ },
+ metadata::{
+ self,
+ ContentHash,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Show {
+ #[clap(flatten)]
+ common: Common,
+ /// Blob hash to show
+ ///
+ /// Instead of looking for an id.json in the tree --ref points to, load a
+ /// particular id.json by hash. If given, --ref is ignored.
+ #[clap(long = "hash", value_parser, value_name = "OID")]
+ blob_hash: Option<git2::Oid>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ repo: PathBuf,
+ #[serde(rename = "ref")]
+ refname: Refname,
+ hash: ContentHash,
+ status: Status,
+ data: metadata::Signed<metadata::Identity>,
+}
+
+#[derive(serde::Serialize)]
+#[serde(rename_all = "UPPERCASE")]
+pub enum Status {
+ Verified {
+ id: metadata::IdentityId,
+ },
+ #[serde(with = "crate::serde::display")]
+ Invalid(metadata::error::Verification),
+}
+
+impl From<Result<metadata::IdentityId, metadata::error::Verification>> for Status {
+ fn from(r: Result<metadata::IdentityId, metadata::error::Verification>) -> Self {
+ r.map(|id| Self::Verified { id })
+ .unwrap_or_else(Self::Invalid)
+ }
+}
+
+pub fn show(args: Show) -> cmd::Result<Output> {
+ let (repo, refname) = args.common.resolve()?;
+
+ let GitIdentity { hash, signed } = match args.blob_hash {
+ None => metadata::Identity::from_tip(&repo, &refname)?,
+ Some(oid) => metadata::Identity::from_blob(&repo.find_blob(oid)?)?,
+ };
+ let status = signed.verify(cmd::find_parent(&repo)).into();
+
+ Ok(Output {
+ repo: repo.path().to_owned(),
+ refname,
+ hash,
+ status,
+ data: signed,
+ })
+}
diff --git a/src/cmd/id/sign.rs b/src/cmd/id/sign.rs
new file mode 100644
index 0000000..b63ef94
--- /dev/null
+++ b/src/cmd/id/sign.rs
@@ -0,0 +1,221 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::collections::BTreeMap;
+
+use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+ Context as _,
+};
+
+use super::{
+ edit,
+ Common,
+};
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ id::META_FILE_ID,
+ ui::{
+ self,
+ edit_commit_message,
+ info,
+ },
+ FromGit as _,
+ GitIdentity,
+ },
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ },
+ metadata,
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Sign {
+ #[clap(flatten)]
+ common: Common,
+ /// Commit to this branch if the signature threshold is met
+ #[clap(short = 'b', long, value_parser, value_name = "REF")]
+ commit_to: Refname,
+ /// Check out the committed changes
+ ///
+ /// Only has an effect if the repository is non-bare.
+ #[clap(long, value_parser)]
+ checkout: bool,
+ /// Don't commit anything to disk
+ #[clap(long, value_parser)]
+ dry_run: bool,
+ /// Commit message for this edit
+ ///
+ /// Like git, $EDITOR will be invoked if not specified.
+ #[clap(short, long, value_parser)]
+ message: Option<String>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn sign(args: Sign) -> cmd::Result<Output> {
+ let (repo, refname) = args.common.resolve()?;
+ let mut tx = refs::Transaction::new(&repo)?;
+ let _tip = tx.lock_ref(refname.clone())?;
+
+ let GitIdentity {
+ signed:
+ metadata::Signed {
+ signed: proposed,
+ signatures: proposed_signatures,
+ },
+ ..
+ } = metadata::Identity::from_tip(&repo, &refname)?;
+ let prev_hash: git2::Oid = proposed
+ .prev
+ .as_ref()
+ .ok_or_else(|| anyhow!("cannot sign a genesis revision"))?
+ .into();
+ let (parent, target_ref) = if refname == args.commit_to {
+ // Signing in-place is only legal if the proposed update already
+ // meets the signature threshold
+ let _ = proposed
+ .verify(&proposed_signatures, cmd::find_parent(&repo))
+ .context("proposed update does not meet the signature threshold")?;
+ (proposed.clone(), repo.find_reference(&args.commit_to)?)
+ } else {
+ let target_ref = if_not_found_none(repo.find_reference(&args.commit_to))?;
+ match target_ref {
+ // If the target ref exists, it must yield a verified id.json whose
+ // blob hash equals the 'prev' hash of the proposed update
+ Some(tgt) => {
+ let parent_commit = tgt.peel_to_commit()?;
+ let GitIdentity {
+ hash: parent_hash,
+ signed:
+ metadata::Signed {
+ signed: parent,
+ signatures: parent_signatures,
+ },
+ } = metadata::Identity::from_commit(&repo, &parent_commit).with_context(|| {
+ format!("failed to load {} from {}", META_FILE_ID, &args.commit_to)
+ })?;
+ let _ = parent
+ .verify(&parent_signatures, cmd::find_parent(&repo))
+ .with_context(|| format!("target {} could not be verified", &args.commit_to))?;
+ ensure!(
+ parent_hash == prev_hash,
+ "parent hash (.prev) doesn't match"
+ );
+
+ (parent, tgt)
+ },
+
+ // If the target ref is unborn, the proposed's parent commit must
+ // yield a verified id.json, as we will create the target from
+ // HEAD^1
+ None => {
+ let parent_commit = repo
+ .find_reference(&refname)?
+ .peel_to_commit()?
+ .parents()
+ .next()
+ .ok_or_else(|| anyhow!("cannot sign an initial commit"))?;
+ let GitIdentity {
+ hash: parent_hash,
+ signed:
+ metadata::Signed {
+ signed: parent,
+ signatures: parent_signatures,
+ },
+ } = metadata::Identity::from_commit(&repo, &parent_commit)?;
+ let _ = parent
+ .verify(&parent_signatures, cmd::find_parent(&repo))
+ .with_context(|| {
+ format!(
+ "parent commit {} of {} could not be verified",
+ parent_commit.id(),
+ refname
+ )
+ })?;
+ ensure!(
+ parent_hash == prev_hash,
+ "parent hash (.prev) doesn't match"
+ );
+
+ let tgt = repo.reference(
+ &args.commit_to,
+ parent_commit.id(),
+ false,
+ &format!("branch: Created from {}^1", refname),
+ )?;
+
+ (parent, tgt)
+ },
+ }
+ };
+ let commit_to = tx.lock_ref(args.commit_to)?;
+
+ let canonical = proposed.canonicalise()?;
+ let mut signer = cfg::signer(&repo.config()?, ui::askpass)?;
+ let mut signatures = BTreeMap::new();
+ let keyid = metadata::KeyId::from(signer.ident());
+ if !parent.keys.contains_key(&keyid) && !proposed.keys.contains_key(&keyid) {
+ bail!("key {} is not eligible to sign the document", keyid);
+ }
+ if proposed_signatures.contains_key(&keyid) {
+ bail!("proposed update is already signed with key {}", keyid);
+ }
+
+ let signature = signer.sign(&canonical)?;
+ signatures.insert(keyid, metadata::Signature::from(signature));
+ signatures.extend(proposed_signatures);
+
+ let _ = proposed
+ .verify(&signatures, cmd::find_parent(&repo))
+ .context("proposal could not be verified after signing")?;
+
+ let signed = metadata::Signed {
+ signed: metadata::Metadata::identity(proposed),
+ signatures,
+ };
+
+ let parent_commit = target_ref.peel_to_commit()?;
+ let parent_tree = parent_commit.tree()?;
+ let on_head = !repo.is_bare() && git2::Branch::wrap(target_ref).is_head();
+
+ let tree = if on_head {
+ edit::write_tree(&repo, &signed)
+ } else {
+ edit::write_tree_bare(&repo, &signed, Some(&parent_tree))
+ }?;
+ let msg = args
+ .message
+ .map(Ok)
+ .unwrap_or_else(|| edit_commit_message(&repo, commit_to.name(), &parent_tree, &tree))?;
+ let commit = git::commit_signed(&mut signer, &repo, msg, &tree, &[&parent_commit])?;
+ commit_to.set_target(commit, "it: identity signoff");
+
+ tx.commit()?;
+
+ if on_head {
+ repo.checkout_tree(
+ tree.as_object(),
+ Some(git2::build::CheckoutBuilder::new().safe()),
+ )?;
+ info!("Checked out tree {}", tree.id());
+ }
+
+ Ok(Output {
+ refname: commit_to.into(),
+ commit,
+ })
+}
diff --git a/src/cmd/mergepoint.rs b/src/cmd/mergepoint.rs
new file mode 100644
index 0000000..2bf4f79
--- /dev/null
+++ b/src/cmd/mergepoint.rs
@@ -0,0 +1,75 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::{
+ cmd::{
+ self,
+ patch,
+ },
+ patches,
+};
+
+#[derive(Debug, clap::Subcommand)]
+pub enum Cmd {
+ /// Record a mergepoint in a local repository
+ Record(Record),
+ /// Submit a mergepoint to a remote drop
+ Submit(Submit),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Record(args) => record(args),
+ Self::Submit(args) => submit(args),
+ }
+ .map(cmd::IntoOutput::into_output)
+ }
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Record {
+ #[clap(flatten)]
+ common: patch::Common,
+ /// Allow branches to be uneven with their upstream (if any)
+ #[clap(long, visible_alias = "force", value_parser)]
+ ignore_upstream: bool,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Submit {
+ #[clap(flatten)]
+ common: patch::Common,
+ #[clap(flatten)]
+ remote: patch::Remote,
+ /// Allow branches to be uneven with their upstream (if any)
+ #[clap(long, visible_alias = "force", value_parser)]
+ ignore_upstream: bool,
+}
+
+pub fn record(
+ Record {
+ common,
+ ignore_upstream,
+ }: Record,
+) -> cmd::Result<patches::Record> {
+ patch::create(patch::Kind::Merges {
+ common,
+ remote: None,
+ force: ignore_upstream,
+ })
+}
+
+pub fn submit(
+ Submit {
+ common,
+ remote,
+ ignore_upstream,
+ }: Submit,
+) -> cmd::Result<patches::Record> {
+ patch::create(patch::Kind::Merges {
+ common,
+ remote: Some(remote),
+ force: ignore_upstream,
+ })
+}
diff --git a/src/cmd/patch.rs b/src/cmd/patch.rs
new file mode 100644
index 0000000..a1b781d
--- /dev/null
+++ b/src/cmd/patch.rs
@@ -0,0 +1,77 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::{
+ cmd,
+ patches,
+};
+
+mod create;
+mod prepare;
+
+pub use create::{
+ create,
+ Comment,
+ Common,
+ Kind,
+ Patch,
+ Remote,
+};
+
+#[derive(Debug, clap::Subcommand)]
+pub enum Cmd {
+ /// Record a patch in a local drop history
+ Record(Record),
+ /// Submit a patch to a remote drop
+ Submit(Submit),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Record(args) => record(args),
+ Self::Submit(args) => submit(args),
+ }
+ .map(cmd::IntoOutput::into_output)
+ }
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Record {
+ #[clap(flatten)]
+ common: Common,
+ #[clap(flatten)]
+ patch: Patch,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Submit {
+ #[clap(flatten)]
+ common: Common,
+ #[clap(flatten)]
+ patch: Patch,
+ #[clap(flatten)]
+ remote: Remote,
+}
+
+pub fn record(Record { common, patch }: Record) -> cmd::Result<patches::Record> {
+ create(Kind::Patch {
+ common,
+ remote: None,
+ patch,
+ })
+}
+
+pub fn submit(
+ Submit {
+ common,
+ patch,
+ remote,
+ }: Submit,
+) -> cmd::Result<patches::Record> {
+ create(Kind::Patch {
+ common,
+ remote: Some(remote),
+ patch,
+ })
+}
diff --git a/src/cmd/patch/create.rs b/src/cmd/patch/create.rs
new file mode 100644
index 0000000..7527364
--- /dev/null
+++ b/src/cmd/patch/create.rs
@@ -0,0 +1,483 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ borrow::Cow,
+ collections::BTreeMap,
+ env,
+ path::PathBuf,
+};
+
+use anyhow::anyhow;
+use clap::ValueHint;
+use globset::{
+ GlobSet,
+ GlobSetBuilder,
+};
+use once_cell::sync::Lazy;
+use url::Url;
+
+use super::prepare;
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ ui::{
+ self,
+ debug,
+ info,
+ },
+ util::args::IdSearchPath,
+ Aborted,
+ },
+ git::{
+ self,
+ Refname,
+ },
+ metadata::IdentityId,
+ patches::{
+ self,
+ iter,
+ DropHead,
+ Topic,
+ TrackingBranch,
+ GLOB_IT_BUNDLES,
+ GLOB_IT_IDS,
+ GLOB_IT_TOPICS,
+ REF_HEADS_PATCHES,
+ REF_IT_BUNDLES,
+ REF_IT_PATCHES,
+ REF_IT_SEEN,
+ },
+ paths,
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Common {
+ /// Path to the drop repository
+ #[clap(from_global)]
+ git_dir: PathBuf,
+ /// Path to the source repository
+ ///
+ /// If set, the patch bundle will be created from objects residing in an
+ /// external repository. The main use case for this is to allow a bare
+ /// drop to pull in checkpoints from a local repo with a regular layout
+ /// (ie. non it-aware).
+ #[clap(
+ long = "source-dir",
+ alias = "src-dir",
+ value_parser,
+ value_name = "DIR",
+ value_hint = ValueHint::DirPath,
+ )]
+ src_dir: Option<PathBuf>,
+ /// Identity to assume
+ ///
+ /// If not set as an option nor in the environment, the value of `it.id` in
+ /// the git config is tried.
+ #[clap(short = 'I', long = "identity", value_name = "ID", env = "IT_ID")]
+ id: Option<IdentityId>,
+ /// A list of paths to search for identity repositories
+ #[clap(
+ long,
+ value_parser,
+ value_name = "PATH",
+ env = "IT_ID_PATH",
+ default_value_t,
+ value_hint = ValueHint::DirPath,
+ )]
+ id_path: IdSearchPath,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// IPFS API to publish the patch bundle to
+ ///
+ /// Currently has no effect when submitting a patch to a remote drop. When
+ /// running `ipfs daemon`, the default API address is 'http://127.0.0.1:5001'.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "URL",
+ value_hint = ValueHint::Url,
+ )]
+ ipfs_api: Option<Url>,
+ /// Additional identities to include, eg. to allow commit verification
+ #[clap(long = "add-id", value_parser, value_name = "ID")]
+ ids: Vec<IdentityId>,
+ /// Message to attach to the patch (cover letter, comment)
+ ///
+ /// If not set, $EDITOR will be invoked to author one.
+ #[clap(short, long, value_parser, value_name = "STRING")]
+ message: Option<String>,
+ /// Create the patch, but stop short of submitting / recording it
+ #[clap(long, value_parser)]
+ dry_run: bool,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Remote {
+ /// Url to submit the patch to
+ ///
+ /// Usually one of the alternates from the drop metadata. If not set,
+ /// GIT_DIR is assumed to contain a drop with which the patch can be
+ /// recorded without any network access.
+ #[clap(long, visible_alias = "submit-to", value_parser, value_name = "URL")]
+ url: Url,
+ /// Refname of the drop to record the patch with
+ ///
+ /// We need to pick a local (remote-tracking) drop history in order to
+ /// compute delta bases for the patch. The value is interpreted
+ /// according to "DWIM" rules, i.e. shorthand forms like 'it/patches',
+ /// 'origin/patches' are attempted to be resolved.
+ #[clap(long = "drop", value_parser, value_name = "STRING")]
+ drop_ref: String,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Patch {
+ /// Base branch the patch is against
+ ///
+ /// If --topic is given, the branch must exist in the patch bundle
+ /// --reply-to refers to, or the default entry to reply to on that
+ /// topic. Otherwise, the branch must exist in the drop
+ /// metadata. Shorthand branch names are accepted.
+ ///
+ /// If not given, "main" or "master" is tried, in that order.
+ #[clap(long = "base", value_parser, value_name = "REF")]
+ base: Option<String>,
+ /// Head revision of the patch, in 'git rev-parse' syntax
+ #[clap(
+ long = "head",
+ value_parser,
+ value_name = "REVSPEC",
+ default_value = "HEAD"
+ )]
+ head: String,
+ /// Post the patch to a previously recorded topic
+ #[clap(long, value_parser, value_name = "TOPIC")]
+ topic: Option<Topic>,
+ /// Reply to a particular entry within a topic
+ ///
+ /// Only considered if --topic is given.
+ #[clap(long, value_parser, value_name = "ID")]
+ reply_to: Option<git2::Oid>,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Comment {
+ /// The topic to comment on
+ #[clap(value_parser, value_name = "TOPIC")]
+ topic: Topic,
+ /// Reply to a particular entry within the topic
+ #[clap(long, value_parser, value_name = "ID")]
+ reply_to: Option<git2::Oid>,
+}
+
+pub enum Kind {
+ Merges {
+ common: Common,
+ remote: Option<Remote>,
+ force: bool,
+ },
+ Snapshot {
+ common: Common,
+ },
+ Comment {
+ common: Common,
+ remote: Option<Remote>,
+ comment: Comment,
+ },
+ Patch {
+ common: Common,
+ remote: Option<Remote>,
+ patch: Patch,
+ },
+}
+
+impl Kind {
+ fn common(&self) -> &Common {
+ match self {
+ Self::Merges { common, .. }
+ | Self::Snapshot { common }
+ | Self::Comment { common, .. }
+ | Self::Patch { common, .. } => common,
+ }
+ }
+
+ fn remote(&self) -> Option<&Remote> {
+ match self {
+ Self::Merges { remote, .. }
+ | Self::Comment { remote, .. }
+ | Self::Patch { remote, .. } => remote.as_ref(),
+ Self::Snapshot { .. } => None,
+ }
+ }
+
+ fn accept_options(&self, drop: &DropHead) -> patches::AcceptOptions {
+ let mut options = patches::AcceptOptions::default();
+ match self {
+ Self::Merges { common, .. } => {
+ options.allow_fat_pack = true;
+ options.max_branches = drop.meta.roles.branches.len();
+ options.max_refs = options.max_branches + common.ids.len() + 1;
+ options.max_commits = 100_000;
+ },
+ Self::Snapshot { .. } => {
+ options.allow_fat_pack = true;
+ options.allowed_refs = SNAPSHOT_REFS.clone();
+ options.max_branches = usize::MAX;
+ options.max_refs = usize::MAX;
+ options.max_commits = usize::MAX;
+ options.max_notes = usize::MAX;
+ options.max_tags = usize::MAX;
+ },
+
+ _ => {},
+ }
+
+ options
+ }
+}
+
+struct Resolved {
+ repo: prepare::Repo,
+ signer_id: IdentityId,
+ bundle_dir: PathBuf,
+}
+
+impl Common {
+ fn resolve(&self) -> cmd::Result<Resolved> {
+ let drp = git::repo::open(&self.git_dir)?;
+ let ids = self.id_path.open_git();
+ let src = match self.src_dir.as_ref() {
+ None => {
+ let cwd = env::current_dir()?;
+ (cwd != self.git_dir).then_some(cwd)
+ },
+ Some(dir) => Some(dir.to_owned()),
+ }
+ .as_deref()
+ .map(git::repo::open_bare)
+ .transpose()?;
+
+ debug!(
+ "drop: {}, src: {:?}, ids: {:?}",
+ drp.path().display(),
+ src.as_ref().map(|r| r.path().display()),
+ env::join_paths(ids.iter().map(|r| r.path()))
+ );
+
+ // IT_ID_PATH could differ from what was used at initialisation
+ git::add_alternates(&drp, &ids)?;
+
+ let repo = prepare::Repo::new(drp, ids, src);
+ let signer_id = match self.id {
+ Some(id) => id,
+ None => cfg::git::identity(&repo.source().config()?)?
+ .ok_or_else(|| anyhow!("no identity configured for signer"))?,
+ };
+ let bundle_dir = if self.bundle_dir.is_absolute() {
+ self.bundle_dir.clone()
+ } else {
+ repo.target().path().join(&self.bundle_dir)
+ };
+
+ Ok(Resolved {
+ repo,
+ signer_id,
+ bundle_dir,
+ })
+ }
+}
+
+static SNAPSHOT_REFS: Lazy<GlobSet> = Lazy::new(|| {
+ GlobSetBuilder::new()
+ .add(GLOB_IT_TOPICS.clone())
+ .add(GLOB_IT_BUNDLES.clone())
+ .add(GLOB_IT_IDS.clone())
+ .build()
+ .unwrap()
+});
+
+pub fn create(args: Kind) -> cmd::Result<patches::Record> {
+ let Resolved {
+ repo,
+ signer_id,
+ bundle_dir,
+ } = args.common().resolve()?;
+ let drop_ref: Cow<str> = match args.remote() {
+ Some(remote) => {
+ let full = repo
+ .source()
+ .resolve_reference_from_short_name(&remote.drop_ref)?;
+ full.name()
+ .ok_or_else(|| anyhow!("invalid drop ref"))?
+ .to_owned()
+ .into()
+ },
+ None if repo.target().is_bare() => REF_HEADS_PATCHES.into(),
+ None => REF_IT_PATCHES.into(),
+ };
+
+ let mut signer = cfg::git::signer(&repo.source().config()?, ui::askpass)?;
+ let drop = patches::DropHead::from_refname(repo.target(), &drop_ref)?;
+
+ let spec = match &args {
+ Kind::Merges { force, .. } => prepare::Kind::Mergepoint { force: *force },
+ Kind::Snapshot { .. } => prepare::Kind::Snapshot { incremental: true },
+ Kind::Comment { comment, .. } => prepare::Kind::Comment {
+ topic: comment.topic.clone(),
+ reply: comment.reply_to,
+ },
+ Kind::Patch { patch, .. } => {
+ let (name, base_ref) = dwim_base(
+ repo.target(),
+ &drop,
+ patch.topic.as_ref(),
+ patch.reply_to,
+ patch.base.as_deref(),
+ )?
+ .ok_or_else(|| anyhow!("unable to determine base branch"))?;
+ let base = repo
+ .target()
+ .find_reference(&base_ref)?
+ .peel_to_commit()?
+ .id();
+ let head = repo
+ .source()
+ .revparse_single(&patch.head)?
+ .peel_to_commit()?
+ .id();
+
+ prepare::Kind::Patch {
+ head,
+ base,
+ name,
+ re: patch.topic.as_ref().map(|t| (t.clone(), patch.reply_to)),
+ }
+ },
+ };
+
+ let mut patch = prepare::Preparator::new(
+ &repo,
+ &drop,
+ prepare::Submitter {
+ signer: &mut signer,
+ id: signer_id,
+ },
+ )
+ .prepare_patch(
+ &bundle_dir,
+ spec,
+ args.common().message.clone(),
+ &args.common().ids,
+ )?;
+
+ if args.common().dry_run {
+ info!("--dry-run given, stopping here");
+ cmd::abort!();
+ }
+
+ match args.remote() {
+ Some(remote) => patch.submit(remote.url.clone()),
+ None => patch.try_accept(patches::AcceptArgs {
+ unbundle_prefix: REF_IT_BUNDLES,
+ drop_ref: &drop_ref,
+ seen_ref: REF_IT_SEEN,
+ repo: repo.target(),
+ signer: &mut signer,
+ ipfs_api: args.common().ipfs_api.as_ref(),
+ options: args.accept_options(&drop),
+ }),
+ }
+}
+
+fn dwim_base(
+ repo: &git2::Repository,
+ drop: &DropHead,
+ topic: Option<&Topic>,
+ reply_to: Option<git2::Oid>,
+ base: Option<&str>,
+) -> cmd::Result<Option<(Refname, Refname)>> {
+ let mut candidates = BTreeMap::new();
+ match topic {
+ Some(topic) => {
+ let reply_to = reply_to.map(Ok).unwrap_or_else(|| {
+ iter::topic::default_reply_to(repo, topic)?
+ .ok_or_else(|| anyhow!("topic {topic} not found"))
+ })?;
+ let mut patch_id = None;
+ for note in iter::topic(repo, topic) {
+ let note = note?;
+ if note.header.id == reply_to {
+ patch_id = Some(note.header.patch.id);
+ break;
+ }
+ }
+ let patch_id = patch_id.ok_or_else(|| {
+ anyhow!("no patch found corresponding to topic: {topic}, reply-to: {reply_to}")
+ })?;
+
+ let prefix = format!("{REF_IT_BUNDLES}/{patch_id}/");
+ let mut iter = repo.references_glob(&format!("{prefix}**"))?;
+ for candidate in iter.names() {
+ let candidate = candidate?;
+ if let Some(suf) = candidate.strip_prefix(&prefix) {
+ if !suf.starts_with("it/") {
+ candidates.insert(format!("refs/{suf}"), candidate.parse()?);
+ }
+ }
+ }
+ },
+
+ None => candidates.extend(
+ drop.meta
+ .roles
+ .branches
+ .keys()
+ .cloned()
+ .map(|name| (name.to_string(), name)),
+ ),
+ };
+
+ const FMTS: &[fn(&str) -> String] = &[
+ |s| s.to_owned(),
+ |s| format!("refs/{}", s),
+ |s| format!("refs/heads/{}", s),
+ |s| format!("refs/tags/{}", s),
+ ];
+
+ debug!("dwim candidates: {candidates:#?}");
+
+ match base {
+ Some(base) => {
+ for (virt, act) in candidates {
+ for f in FMTS {
+ let name = f(base);
+ if name == virt {
+ let refname = name.parse()?;
+ return Ok(Some((refname, act)));
+ }
+ }
+ }
+ Ok(None)
+ },
+
+ // nb. biased towards "main" because we use a BTreeMap
+ None => Ok(candidates.into_iter().find_map(|(k, _)| match k.as_str() {
+ "refs/heads/main" => Some((Refname::main(), TrackingBranch::main().into_refname())),
+ "refs/heads/master" => {
+ Some((Refname::master(), TrackingBranch::master().into_refname()))
+ },
+ _ => None,
+ })),
+ }
+}
diff --git a/src/cmd/patch/prepare.rs b/src/cmd/patch/prepare.rs
new file mode 100644
index 0000000..06d5ec9
--- /dev/null
+++ b/src/cmd/patch/prepare.rs
@@ -0,0 +1,615 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::path::{
+ Path,
+ PathBuf,
+};
+
+use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+};
+use either::Either::Left;
+use sha2::{
+ Digest,
+ Sha256,
+};
+
+use crate::{
+ bundle,
+ cmd::{
+ self,
+ ui::{
+ debug,
+ edit_comment,
+ edit_cover_letter,
+ info,
+ warn,
+ },
+ },
+ git::{
+ self,
+ if_not_found_none,
+ Refname,
+ },
+ keys::Signer,
+ metadata::{
+ self,
+ git::{
+ FromGit,
+ GitMeta,
+ META_FILE_ID,
+ },
+ identity::{
+ self,
+ IdentityId,
+ },
+ ContentHash,
+ KeyId,
+ },
+ patches::{
+ self,
+ iter::{
+ dropped,
+ topic,
+ },
+ notes,
+ record,
+ Topic,
+ REF_IT_BUNDLES,
+ REF_IT_PATCHES,
+ TOPIC_MERGES,
+ TOPIC_SNAPSHOTS,
+ },
+};
+
+pub enum Kind {
+ Mergepoint {
+ force: bool,
+ },
+ Snapshot {
+ incremental: bool,
+ },
+ Patch {
+ head: git2::Oid,
+ base: git2::Oid,
+ name: Refname,
+ re: Option<(Topic, Option<git2::Oid>)>,
+ },
+ Comment {
+ topic: Topic,
+ reply: Option<git2::Oid>,
+ },
+}
+
+pub struct Submitter<'a, S: ?Sized> {
+ pub signer: &'a mut S,
+ pub id: IdentityId,
+}
+
+pub struct Repo {
+ drp: git2::Repository,
+ src: Option<git2::Repository>,
+ ids: Vec<git2::Repository>,
+}
+
+impl Repo {
+ pub fn new(
+ drp: git2::Repository,
+ ids: Vec<git2::Repository>,
+ src: Option<git2::Repository>,
+ ) -> Self {
+ Self { drp, ids, src }
+ }
+
+ /// Repository containing the patch objects
+ pub fn source(&self) -> &git2::Repository {
+ self.src.as_ref().unwrap_or(&self.drp)
+ }
+
+ /// Repository containing the drop state
+ pub fn target(&self) -> &git2::Repository {
+ &self.drp
+ }
+
+ /// Repositories containing identity histories
+ pub fn id_path(&self) -> &[git2::Repository] {
+ &self.ids
+ }
+}
+
+pub struct Preparator<'a, S: ?Sized> {
+ repo: &'a Repo,
+ drop: &'a patches::DropHead<'a>,
+ submitter: Submitter<'a, S>,
+}
+
+impl<'a, S: Signer> Preparator<'a, S> {
+ pub fn new(
+ repo: &'a Repo,
+ drop: &'a patches::DropHead<'a>,
+ submitter: Submitter<'a, S>,
+ ) -> Self {
+ Self {
+ repo,
+ drop,
+ submitter,
+ }
+ }
+
+ pub fn prepare_patch(
+ &mut self,
+ bundle_dir: &Path,
+ kind: Kind,
+ message: Option<String>,
+ additional_ids: &[IdentityId],
+ ) -> cmd::Result<patches::Submission> {
+ let mut header = bundle::Header::default();
+
+ match kind {
+ Kind::Mergepoint { force } => {
+ mergepoint(self.repo, &self.drop.meta, &mut header, force)?;
+ ensure!(
+ !header.references.is_empty(),
+ "refusing to create empty checkpoint"
+ );
+ self.annotate_checkpoint(&mut header, &TOPIC_MERGES, message)?;
+ },
+ Kind::Snapshot { incremental } => {
+ snapshot(self.repo, &mut header, incremental)?;
+ ensure!(
+ !header.references.is_empty(),
+ "refusing to create empty snapshot"
+ );
+ self.annotate_checkpoint(&mut header, &TOPIC_SNAPSHOTS, message)?;
+ },
+ Kind::Patch {
+ head,
+ base,
+ name,
+ re,
+ } => {
+ ensure!(base != head, "refusing to create empty patch");
+ ensure!(
+ if_not_found_none(self.repo.source().merge_base(base, head))?.is_some(),
+ "{base} is not reachable from {head}"
+ );
+ info!("Adding patch for {name}: {base}..{head}");
+ header.add_prerequisite(&base);
+ header.add_reference(name, &head);
+ self.annotate_patch(&mut header, message, re)?;
+ },
+ Kind::Comment { topic, reply } => {
+ self.annotate_comment(&mut header, topic, message, reply)?;
+ },
+ }
+
+ for id in additional_ids {
+ Identity::find(
+ self.repo.target(),
+ &self.drop.ids,
+ self.repo.id_path(),
+ cmd::id::identity_ref(Left(id))?,
+ )?
+ .update(&mut header);
+ }
+
+ let signer_hash = {
+ let keyid = self.submitter.signer.ident().keyid();
+ let id_ref = cmd::id::identity_ref(Left(&self.submitter.id))?;
+ let id = Identity::find(
+ self.repo.target(),
+ &self.drop.ids,
+ self.repo.id_path(),
+ id_ref,
+ )?;
+ ensure!(
+ id.contains(&keyid),
+ "signing key {keyid} not in identity {}",
+ id.id()
+ );
+ id.update(&mut header);
+
+ id.hash().clone()
+ };
+
+ let bundle = patches::Bundle::create(bundle_dir, self.repo.source(), header)?;
+ let signature = bundle
+ .sign(self.submitter.signer)
+ .map(|signature| patches::Signature {
+ signer: signer_hash,
+ signature: signature.into(),
+ })?;
+
+ Ok(patches::Submission { signature, bundle })
+ }
+
+ fn annotate_checkpoint(
+ &mut self,
+ bundle: &mut bundle::Header,
+ topic: &Topic,
+ message: Option<String>,
+ ) -> cmd::Result<()> {
+ let kind = if topic == &*TOPIC_MERGES {
+ notes::CheckpointKind::Merge
+ } else if topic == &*TOPIC_SNAPSHOTS {
+ notes::CheckpointKind::Snapshot
+ } else {
+ bail!("not a checkpoint topic: {topic}")
+ };
+ let note = notes::Simple::checkpoint(kind, bundle.references.clone(), message);
+ let parent = topic::default_reply_to(self.repo.target(), topic)?
+ .map(|id| self.repo.source().find_commit(id))
+ .transpose()?;
+
+ self.annotate(bundle, topic, parent, &note)
+ }
+
+ fn annotate_patch(
+ &mut self,
+ bundle: &mut bundle::Header,
+ cover: Option<String>,
+ re: Option<(Topic, Option<git2::Oid>)>,
+ ) -> cmd::Result<()> {
+ let cover = cover
+ .map(notes::Simple::new)
+ .map(Ok)
+ .unwrap_or_else(|| edit_cover_letter(self.repo.source()))?;
+ let (topic, parent) = match re {
+ Some((topic, reply_to)) => {
+ let parent = find_reply_to(self.repo, &topic, reply_to)?;
+ (topic, Some(parent))
+ },
+ None => {
+ // This is pretty arbitrary -- just use a random string instead?
+ let topic = {
+ let mut hasher = Sha256::new();
+ hasher.update(record::Heads::from(bundle as &bundle::Header));
+ serde_json::to_writer(&mut hasher, &cover)?;
+ hasher.update(self.submitter.signer.ident().keyid());
+ Topic::from(hasher.finalize())
+ };
+ let parent = topic::default_reply_to(self.repo.target(), &topic)?
+ .map(|id| self.repo.source().find_commit(id))
+ .transpose()?;
+
+ (topic, parent)
+ },
+ };
+
+ self.annotate(bundle, &topic, parent, &cover)
+ }
+
+ fn annotate_comment(
+ &mut self,
+ bundle: &mut bundle::Header,
+ topic: Topic,
+ message: Option<String>,
+ reply_to: Option<git2::Oid>,
+ ) -> cmd::Result<()> {
+ let parent = find_reply_to(self.repo, &topic, reply_to)?;
+ let edit = || -> cmd::Result<notes::Simple> {
+ let re = notes::Simple::from_commit(self.repo.target(), &parent)?;
+ edit_comment(self.repo.source(), Some(&re))
+ };
+ let comment = message
+ .map(notes::Simple::new)
+ .map(Ok)
+ .unwrap_or_else(edit)?;
+
+ self.annotate(bundle, &topic, Some(parent), &comment)
+ }
+
+ fn annotate(
+ &mut self,
+ bundle: &mut bundle::Header,
+ topic: &Topic,
+ parent: Option<git2::Commit>,
+ note: &notes::Simple,
+ ) -> cmd::Result<()> {
+ let repo = self.repo.source();
+ let topic_ref = topic.as_refname();
+ let tree = {
+ let mut tb = repo.treebuilder(None)?;
+ patches::to_tree(repo, &mut tb, note)?;
+ repo.find_tree(tb.write()?)?
+ };
+ let msg = match note.subject() {
+ Some(s) => format!("{}\n\n{}", s, topic.as_trailer()),
+ None => topic.as_trailer(),
+ };
+ let commit = git::commit_signed(
+ self.submitter.signer,
+ repo,
+ &msg,
+ &tree,
+ parent.as_ref().into_iter().collect::<Vec<_>>().as_slice(),
+ )?;
+
+ if let Some(commit) = parent {
+ bundle.add_prerequisite(&commit.id());
+ }
+ bundle.add_reference(topic_ref, &commit);
+
+ Ok(())
+ }
+}
+
+fn mergepoint(
+ repos: &Repo,
+ meta: &metadata::drop::Verified,
+ bundle: &mut bundle::Header,
+ force: bool,
+) -> git::Result<()> {
+ for branch in meta.roles.branches.keys() {
+ let sandboxed = match patches::TrackingBranch::try_from(branch) {
+ Ok(tracking) => tracking,
+ Err(e) => {
+ warn!("Skipping invalid branch {branch}: {e}");
+ continue;
+ },
+ };
+ let head = {
+ let local = repos.source().find_reference(branch)?;
+ let head = local.peel_to_commit()?.id();
+ if !force {
+ if let Some(upstream) = if_not_found_none(git2::Branch::wrap(local).upstream())? {
+ let upstream_head = upstream.get().peel_to_commit()?.id();
+ if head != upstream_head {
+ warn!(
+ "Upstream {} is not even with {branch}; you may want to push first",
+ String::from_utf8_lossy(upstream.name_bytes()?)
+ );
+ info!("Skipping {branch}");
+ continue;
+ }
+ }
+ }
+
+ head
+ };
+ match if_not_found_none(repos.target().find_reference(&sandboxed))? {
+ Some(base) => {
+ let base = base.peel_to_commit()?.id();
+ if base == head {
+ info!("Skipping empty checkpoint");
+ } else if if_not_found_none(repos.source().merge_base(base, head))?.is_some() {
+ info!("Adding thin checkpoint for branch {branch}: {base}..{head}");
+ bundle.add_prerequisite(&base);
+ bundle.add_reference(branch.clone(), &head);
+ } else {
+ warn!(
+ "{branch} diverges from drop state: no merge base between {base}..{head}"
+ );
+ }
+ },
+
+ None => {
+ info!("Adding full checkpoint for branch {branch}: {head}");
+ bundle.add_reference(branch.clone(), &head);
+ },
+ }
+ }
+
+ Ok(())
+}
+
+fn snapshot(repo: &Repo, bundle: &mut bundle::Header, incremental: bool) -> cmd::Result<()> {
+ for record in dropped::records(repo.target(), REF_IT_PATCHES) {
+ let record = record?;
+ let bundle_hash = record.bundle_hash();
+ if record.is_encrypted() {
+ warn!("Skipping encrypted patch bundle {bundle_hash}",);
+ continue;
+ }
+
+ if record.topic == *TOPIC_SNAPSHOTS {
+ if !incremental {
+ debug!("Full snapshot: skipping previous snapshot {bundle_hash}");
+ continue;
+ } else {
+ info!("Incremental snapshot: found previous snapshot {bundle_hash}");
+ for oid in record.meta.bundle.references.values().copied() {
+ info!("Adding prerequisite {oid} from {bundle_hash}");
+ bundle.add_prerequisite(oid);
+ }
+ break;
+ }
+ }
+
+ info!("Including {bundle_hash} in snapshot");
+ for (name, oid) in &record.meta.bundle.references {
+ info!("Adding {oid} {name}");
+ let name = patches::unbundled_ref(REF_IT_BUNDLES, &record, name)?;
+ bundle.add_reference(name, *oid);
+ }
+ }
+
+ Ok(())
+}
+
+fn find_reply_to<'a>(
+ repo: &'a Repo,
+ topic: &Topic,
+ reply_to: Option<git2::Oid>,
+) -> cmd::Result<git2::Commit<'a>> {
+ let tip = if_not_found_none(repo.target().refname_to_id(&topic.as_refname()))?
+ .ok_or_else(|| anyhow!("topic {topic} does not exist"))?;
+ let id = match reply_to {
+ Some(id) => {
+ ensure!(
+ repo.target().graph_descendant_of(tip, id)?,
+ "{id} not found in topic {topic}, cannot reply"
+ );
+ id
+ },
+ None => topic::default_reply_to(repo.target(), topic)?.expect("impossible: empty topic"),
+ };
+
+ Ok(repo.source().find_commit(id)?)
+}
+
+struct Identity {
+ hash: ContentHash,
+ verified: identity::Verified,
+ update: Option<Range>,
+}
+
+impl Identity {
+ pub fn find(
+ repo: &git2::Repository,
+ ids: &git2::Tree,
+ id_path: &[git2::Repository],
+ refname: Refname,
+ ) -> cmd::Result<Self> {
+ let find_parent = metadata::git::find_parent(repo);
+
+ struct Meta {
+ hash: ContentHash,
+ id: identity::Verified,
+ }
+
+ impl Meta {
+ fn identity(&self) -> &metadata::Identity {
+ self.id.identity()
+ }
+ }
+
+ let (ours_in, ours) =
+ metadata::Identity::from_search_path(id_path, &refname).and_then(|data| {
+ let signer = data.meta.signed.verified(&find_parent)?;
+ Ok((
+ data.repo,
+ Meta {
+ hash: data.meta.hash,
+ id: signer,
+ },
+ ))
+ })?;
+
+ let tree_path = PathBuf::from(ours.id.id().to_string()).join(META_FILE_ID);
+ let newer = match if_not_found_none(ids.get_path(&tree_path))? {
+ None => {
+ let start = ours_in.refname_to_id(&refname)?;
+ let range = Range {
+ refname,
+ start,
+ end: None,
+ };
+ Self {
+ hash: ours.hash,
+ verified: ours.id,
+ update: Some(range),
+ }
+ },
+ Some(in_tree) if ours.hash == in_tree.id() => Self {
+ hash: ours.hash,
+ verified: ours.id,
+ update: None,
+ },
+ Some(in_tree) => {
+ let theirs = metadata::Identity::from_blob(&repo.find_blob(in_tree.id())?)
+ .and_then(|GitMeta { hash, signed }| {
+ let signer = signed.verified(&find_parent)?;
+ Ok(Meta { hash, id: signer })
+ })?;
+
+ if ours.identity().has_ancestor(&theirs.hash, &find_parent)? {
+ let range = Range::compute(ours_in, refname, theirs.hash.as_oid())?;
+ Self {
+ hash: ours.hash,
+ verified: ours.id,
+ update: range,
+ }
+ } else if theirs.identity().has_ancestor(&ours.hash, &find_parent)? {
+ Self {
+ hash: theirs.hash,
+ verified: theirs.id,
+ update: None,
+ }
+ } else {
+ bail!(
+ "provided identity at {} diverges from in-tree at {}",
+ ours.hash,
+ theirs.hash,
+ )
+ }
+ },
+ };
+
+ Ok(newer)
+ }
+
+ pub fn id(&self) -> &IdentityId {
+ self.verified.id()
+ }
+
+ pub fn hash(&self) -> &ContentHash {
+ &self.hash
+ }
+
+ pub fn contains(&self, key: &KeyId) -> bool {
+ self.verified.identity().keys.contains_key(key)
+ }
+
+ pub fn update(&self, bundle: &mut bundle::Header) {
+ if let Some(range) = &self.update {
+ range.add_to_bundle(bundle);
+ }
+ }
+}
+
+struct Range {
+ refname: Refname,
+ start: git2::Oid,
+ end: Option<git2::Oid>,
+}
+
+impl Range {
+ fn compute(
+ repo: &git2::Repository,
+ refname: Refname,
+ known: git2::Oid,
+ ) -> cmd::Result<Option<Self>> {
+ let start = repo.refname_to_id(&refname)?;
+
+ let mut walk = repo.revwalk()?;
+ walk.push(start)?;
+ for oid in walk {
+ let oid = oid?;
+ let blob_id = repo
+ .find_commit(oid)?
+ .tree()?
+ .get_name(META_FILE_ID)
+ .ok_or_else(|| anyhow!("corrupt identity: missing {META_FILE_ID}"))?
+ .id();
+
+ if blob_id == known {
+ return Ok(if oid == start {
+ None
+ } else {
+ Some(Self {
+ refname,
+ start,
+ end: Some(oid),
+ })
+ });
+ }
+ }
+
+ Ok(Some(Self {
+ refname,
+ start,
+ end: None,
+ }))
+ }
+
+ fn add_to_bundle(&self, header: &mut bundle::Header) {
+ header.add_reference(self.refname.clone(), &self.start);
+ if let Some(end) = self.end {
+ header.add_prerequisite(&end);
+ }
+ }
+}
diff --git a/src/cmd/topic.rs b/src/cmd/topic.rs
new file mode 100644
index 0000000..fe4e2df
--- /dev/null
+++ b/src/cmd/topic.rs
@@ -0,0 +1,58 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::path::PathBuf;
+
+use crate::cmd;
+
+pub mod comment;
+
+mod ls;
+pub use ls::{
+ ls,
+ Ls,
+};
+
+mod show;
+pub use show::{
+ show,
+ Show,
+};
+
+mod unbundle;
+pub use unbundle::{
+ unbundle,
+ Unbundle,
+};
+
+#[derive(Debug, clap::Subcommand)]
+#[allow(clippy::large_enum_variant)]
+pub enum Cmd {
+ /// List the recorded topics
+ Ls(Ls),
+ /// Show a topic
+ Show(Show),
+ /// Comment on a topic
+ #[clap(subcommand)]
+ Comment(comment::Cmd),
+ /// Unbundle a topic
+ Unbundle(Unbundle),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Ls(args) => ls(args).map(cmd::Output::iter),
+ Self::Show(args) => show(args).map(cmd::Output::iter),
+ Self::Comment(cmd) => cmd.run(),
+ Self::Unbundle(args) => unbundle(args).map(cmd::Output::val),
+ }
+ }
+}
+
+#[derive(Debug, clap::Args)]
+struct Common {
+ /// Path to the drop repository
+ #[clap(from_global)]
+ git_dir: PathBuf,
+}
diff --git a/src/cmd/topic/comment.rs b/src/cmd/topic/comment.rs
new file mode 100644
index 0000000..121dabb
--- /dev/null
+++ b/src/cmd/topic/comment.rs
@@ -0,0 +1,68 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::{
+ cmd::{
+ self,
+ patch,
+ },
+ patches,
+};
+
+#[derive(Debug, clap::Subcommand)]
+pub enum Cmd {
+ /// Record the comment with a local drop history
+ Record(Record),
+ /// Submit the comment to a remote drop
+ Submit(Submit),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Record(args) => record(args),
+ Self::Submit(args) => submit(args),
+ }
+ .map(cmd::IntoOutput::into_output)
+ }
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Record {
+ #[clap(flatten)]
+ common: patch::Common,
+ #[clap(flatten)]
+ comment: patch::Comment,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Submit {
+ #[clap(flatten)]
+ common: patch::Common,
+ #[clap(flatten)]
+ comment: patch::Comment,
+ #[clap(flatten)]
+ remote: patch::Remote,
+}
+
+pub fn record(Record { common, comment }: Record) -> cmd::Result<patches::Record> {
+ patch::create(patch::Kind::Comment {
+ common,
+ remote: None,
+ comment,
+ })
+}
+
+pub fn submit(
+ Submit {
+ common,
+ comment,
+ remote,
+ }: Submit,
+) -> cmd::Result<patches::Record> {
+ patch::create(patch::Kind::Comment {
+ common,
+ remote: Some(remote),
+ comment,
+ })
+}
diff --git a/src/cmd/topic/ls.rs b/src/cmd/topic/ls.rs
new file mode 100644
index 0000000..430cc6e
--- /dev/null
+++ b/src/cmd/topic/ls.rs
@@ -0,0 +1,32 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::{
+ git,
+ patches::{
+ self,
+ Topic,
+ },
+};
+
+use super::Common;
+use crate::cmd;
+
+#[derive(Debug, clap::Args)]
+pub struct Ls {
+ #[clap(flatten)]
+ common: Common,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ topic: Topic,
+ subject: String,
+}
+
+pub fn ls(args: Ls) -> cmd::Result<Vec<cmd::Result<Output>>> {
+ let repo = git::repo::open(&args.common.git_dir)?;
+ Ok(patches::iter::unbundled::topics_with_subject(&repo)
+ .map(|i| i.map(|(topic, subject)| Output { topic, subject }))
+ .collect())
+}
diff --git a/src/cmd/topic/show.rs b/src/cmd/topic/show.rs
new file mode 100644
index 0000000..1d19720
--- /dev/null
+++ b/src/cmd/topic/show.rs
@@ -0,0 +1,34 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use super::Common;
+use crate::{
+ cmd,
+ git,
+ patches::{
+ self,
+ iter::Note,
+ Topic,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Show {
+ #[clap(flatten)]
+ common: Common,
+ /// Traverse the topic in reverse order, ie. oldest first
+ #[clap(long, value_parser)]
+ reverse: bool,
+ #[clap(value_parser)]
+ topic: Topic,
+}
+
+pub fn show(args: Show) -> cmd::Result<Vec<cmd::Result<Note>>> {
+ let repo = git::repo::open(&args.common.git_dir)?;
+ let iter = patches::iter::topic(&repo, &args.topic);
+ if args.reverse {
+ Ok(iter.rev().collect())
+ } else {
+ Ok(iter.collect())
+ }
+}
diff --git a/src/cmd/topic/unbundle.rs b/src/cmd/topic/unbundle.rs
new file mode 100644
index 0000000..3aab54b
--- /dev/null
+++ b/src/cmd/topic/unbundle.rs
@@ -0,0 +1,174 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::{
+ BTreeMap,
+ BTreeSet,
+ },
+ path::PathBuf,
+};
+
+use anyhow::anyhow;
+use clap::ValueHint;
+
+use super::Common;
+use crate::{
+ cmd::{
+ self,
+ ui::{
+ debug,
+ info,
+ warn,
+ },
+ Aborted,
+ },
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ Refname,
+ },
+ metadata::{
+ self,
+ git::FromGit,
+ },
+ patches::{
+ self,
+ iter::dropped,
+ Bundle,
+ Record,
+ Topic,
+ REF_IT_BUNDLES,
+ REF_IT_PATCHES,
+ TOPIC_MERGES,
+ TOPIC_SNAPSHOTS,
+ },
+ paths,
+};
+
+// TODO:
+//
+// - don't require patch bundle to be present on-disk when snapshots would do
+
+#[derive(Debug, clap::Args)]
+pub struct Unbundle {
+ #[clap(flatten)]
+ common: Common,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// The topic to unbundle
+ #[clap(value_parser)]
+ topic: Topic,
+ /// The drop history to find the topic in
+ #[clap(value_parser)]
+ drop: Option<String>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ updated: BTreeMap<Refname, git::serde::oid::Oid>,
+}
+
+pub fn unbundle(args: Unbundle) -> cmd::Result<Output> {
+ let repo = git::repo::open(&args.common.git_dir)?;
+ let bundle_dir = if args.bundle_dir.is_relative() {
+ repo.path().join(args.bundle_dir)
+ } else {
+ args.bundle_dir
+ };
+ let drop = match args.drop {
+ Some(rev) => if_not_found_none(repo.resolve_reference_from_short_name(&rev))?
+ .ok_or_else(|| anyhow!("no ref matching {rev} found"))?
+ .name()
+ .ok_or_else(|| anyhow!("invalid drop"))?
+ .to_owned(),
+ None => REF_IT_PATCHES.to_owned(),
+ };
+
+ let filter = [&args.topic, &TOPIC_MERGES, &TOPIC_SNAPSHOTS];
+ let mut on_topic: Vec<Record> = Vec::new();
+ let mut checkpoints: Vec<Record> = Vec::new();
+ for row in dropped::topics(&repo, &drop) {
+ let (t, id) = row?;
+
+ if filter.into_iter().any(|f| f == &t) {
+ let commit = repo.find_commit(id)?;
+ let record = Record::from_commit(&repo, &commit)?;
+ if t == args.topic {
+ on_topic.push(record);
+ continue;
+ }
+
+ // Skip checkpoint which came after the most recent record on the topic
+ if !on_topic.is_empty() {
+ checkpoints.push(record);
+ }
+ }
+ }
+
+ let odb = repo.odb()?;
+
+ info!("Indexing checkpoints...");
+ for rec in checkpoints.into_iter().rev() {
+ Bundle::from_stored(&bundle_dir, rec.bundle_info().as_expect())?
+ .packdata()?
+ .index(&odb)?
+ }
+
+ let mut missing = BTreeSet::new();
+ for oid in on_topic
+ .iter()
+ .flat_map(|rec| &rec.bundle_info().prerequisites)
+ {
+ let oid = git2::Oid::try_from(oid)?;
+ if !odb.exists(oid) {
+ missing.insert(oid);
+ }
+ }
+
+ if !missing.is_empty() {
+ warn!("Unable to satisfy all prerequisites");
+ info!("The following prerequisite commits are missing:\n");
+ for oid in missing {
+ info!("{oid}");
+ }
+ info!("\nYou may try to unbundle the entire drop history");
+ cmd::abort!();
+ }
+
+ info!("Unbundling topic records...");
+ let mut tx = refs::Transaction::new(&repo)?;
+ let topic_ref = tx.lock_ref(args.topic.as_refname())?;
+ let mut up = BTreeMap::new();
+ for rec in on_topic.into_iter().rev() {
+ let hash = rec.bundle_hash();
+ let bundle = Bundle::from_stored(&bundle_dir, rec.bundle_info().as_expect())?;
+ if bundle.is_encrypted() {
+ warn!("Skipping encrypted bundle {hash}");
+ continue;
+ }
+ bundle.packdata()?.index(&odb)?;
+ debug!("{hash}: unbundle");
+ let updated = patches::unbundle(&odb, &mut tx, REF_IT_BUNDLES, &rec)?;
+ for (name, oid) in updated {
+ up.insert(name, oid.into());
+ }
+ debug!("{hash}: merge notes");
+ let submitter = metadata::Identity::from_content_hash(&repo, &rec.meta.signature.signer)?
+ .verified(metadata::git::find_parent(&repo))?;
+ patches::merge_notes(&repo, &submitter, &topic_ref, &rec)?;
+ }
+ tx.commit()?;
+
+ Ok(Output { updated: up })
+}
diff --git a/src/cmd/ui.rs b/src/cmd/ui.rs
new file mode 100644
index 0000000..c1ad214
--- /dev/null
+++ b/src/cmd/ui.rs
@@ -0,0 +1,131 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ borrow::Cow,
+ env,
+ ffi::OsStr,
+ io,
+ process::{
+ self,
+ Command,
+ Stdio,
+ },
+};
+
+use anyhow::ensure;
+use console::Term;
+use zeroize::Zeroizing;
+
+use crate::{
+ cmd::{
+ self,
+ Aborted,
+ },
+ patches::notes,
+};
+
+mod editor;
+mod output;
+pub use output::{
+ debug,
+ error,
+ info,
+ warn,
+ Output,
+};
+
+pub fn edit_commit_message(
+ repo: &git2::Repository,
+ branch: &str,
+ old: &git2::Tree,
+ new: &git2::Tree,
+) -> cmd::Result<String> {
+ let diff = repo.diff_tree_to_tree(
+ Some(old),
+ Some(new),
+ Some(
+ git2::DiffOptions::new()
+ .patience(true)
+ .minimal(true)
+ .context_lines(5),
+ ),
+ )?;
+ abort_if_empty(
+ "commit message",
+ editor::Commit::new(repo.path())?.edit(branch, diff),
+ )
+}
+
+pub fn edit_cover_letter(repo: &git2::Repository) -> cmd::Result<notes::Simple> {
+ abort_if_empty(
+ "cover letter",
+ editor::CoverLetter::new(repo.path())?.edit(),
+ )
+}
+
+pub fn edit_comment(
+ repo: &git2::Repository,
+ re: Option<&notes::Simple>,
+) -> cmd::Result<notes::Simple> {
+ abort_if_empty("comment", editor::Comment::new(repo.path())?.edit(re))
+}
+
+pub fn edit_metadata<T>(template: T) -> cmd::Result<T>
+where
+ T: serde::Serialize + serde::de::DeserializeOwned,
+{
+ abort_if_empty("metadata", editor::Metadata::new()?.edit(template))
+}
+
+fn abort_if_empty<T>(ctx: &str, edit: io::Result<Option<T>>) -> cmd::Result<T> {
+ edit?.map(Ok).unwrap_or_else(|| {
+ info!("Aborting due to empty {ctx}");
+ cmd::abort!()
+ })
+}
+
+pub fn askpass(prompt: &str) -> cmd::Result<Zeroizing<Vec<u8>>> {
+ const DEFAULT_ASKPASS: &str = "ssh-askpass";
+
+ fn ssh_askpass() -> Cow<'static, OsStr> {
+ env::var_os("SSH_ASKPASS")
+ .map(Into::into)
+ .unwrap_or_else(|| OsStr::new(DEFAULT_ASKPASS).into())
+ }
+
+ let ssh = env::var_os("SSH_ASKPASS_REQUIRE").and_then(|require| {
+ if require == "force" {
+ Some(ssh_askpass())
+ } else if require == "prefer" {
+ env::var_os("DISPLAY").map(|_| ssh_askpass())
+ } else {
+ None
+ }
+ });
+
+ match ssh {
+ Some(cmd) => {
+ let process::Output { status, stdout, .. } = Command::new(&cmd)
+ .arg(prompt)
+ .stderr(Stdio::inherit())
+ .output()?;
+ ensure!(
+ status.success(),
+ "{} failed with {:?}",
+ cmd.to_string_lossy(),
+ status.code()
+ );
+ Ok(Zeroizing::new(stdout))
+ },
+ None => {
+ let tty = Term::stderr();
+ if tty.is_term() {
+ tty.write_line(prompt)?;
+ }
+ tty.read_secure_line()
+ .map(|s| Zeroizing::new(s.into_bytes()))
+ .map_err(Into::into)
+ },
+ }
+}
diff --git a/src/cmd/ui/editor.rs b/src/cmd/ui/editor.rs
new file mode 100644
index 0000000..a2a7a5e
--- /dev/null
+++ b/src/cmd/ui/editor.rs
@@ -0,0 +1,228 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ env,
+ ffi::OsString,
+ io::{
+ self,
+ BufRead as _,
+ Write as _,
+ },
+ path::{
+ Path,
+ PathBuf,
+ },
+ process::Command,
+};
+
+use tempfile::TempPath;
+
+use crate::{
+ fs::LockedFile,
+ patches::notes,
+};
+
+const SCISSORS: &str = "# ------------------------ >8 ------------------------";
+
+pub struct Commit(Editmsg);
+
+impl Commit {
+ pub fn new<P: AsRef<Path>>(git_dir: P) -> io::Result<Self> {
+ Editmsg::new(git_dir.as_ref().join("COMMIT_EDITMSG")).map(Self)
+ }
+
+ pub fn edit(self, branch: &str, diff: git2::Diff) -> io::Result<Option<String>> {
+ let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch);
+ self.0.edit(|buf| {
+ write!(
+ buf,
+ "
+# Please enter the commit message for your changes. Lines starting
+# with '#' will be ignored, and an empty message aborts the commit.
+#
+# On branch {branch}
+#
+{SCISSORS}
+# Do not modify or remove the line above.
+# Everything below it will be ignored.
+#
+# Changes to be committed:
+"
+ )?;
+ diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
+ use git2::DiffLineType::{
+ Addition,
+ Context,
+ Deletion,
+ };
+ let ok = if matches!(line.origin_value(), Context | Addition | Deletion) {
+ write!(buf, "{}", line.origin()).is_ok()
+ } else {
+ true
+ };
+ ok && buf.write_all(line.content()).is_ok()
+ })
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
+ Ok(())
+ })
+ }
+}
+
+pub struct CoverLetter(Editmsg);
+
+impl CoverLetter {
+ pub fn new<P: AsRef<Path>>(git_dir: P) -> io::Result<Self> {
+ Editmsg::new(git_dir.as_ref().join("NOTES_EDITMSG")).map(Self)
+ }
+
+ // TODO: render patch series a la git log
+ pub fn edit(self) -> io::Result<Option<notes::Simple>> {
+ let txt = self.0.edit(|buf| {
+ writeln!(
+ buf,
+ "
+# Please describe your patch as you would in a cover letter or PR.
+# Lines starting with '#' will be ignored, and an empty message
+# aborts the patch creation.
+#
+{SCISSORS}
+# Do not modify or remove the line above.
+# Everything below it will be ignored.
+#
+# Changes to be committed:
+
+TODO (sorry)
+"
+ )?;
+
+ Ok(())
+ })?;
+
+ Ok(txt.map(notes::Simple::new))
+ }
+}
+
+pub struct Comment(Editmsg);
+
+impl Comment {
+ pub fn new<P: AsRef<Path>>(git_dir: P) -> io::Result<Self> {
+ Editmsg::new(git_dir.as_ref().join("NOTES_EDITMSG")).map(Self)
+ }
+
+ pub fn edit(self, re: Option<&notes::Simple>) -> io::Result<Option<notes::Simple>> {
+ let txt = self.0.edit(|buf| {
+ write!(
+ buf,
+ "
+# Enter your comment above. Lines starting with '#' will be ignored,
+# and an empty message aborts the comment creation.
+"
+ )?;
+
+ if let Some(prev) = re {
+ write!(
+ buf,
+ "#
+{SCISSORS}
+# Do not modify or remove the line above.
+# Everything below it will be ignored.
+#
+# Replying to:
+"
+ )?;
+
+ serde_json::to_writer_pretty(buf, prev)?;
+ }
+
+ Ok(())
+ })?;
+
+ Ok(txt.map(notes::Simple::new))
+ }
+}
+
+pub struct Metadata {
+ _tmp: TempPath,
+ msg: Editmsg,
+}
+
+impl Metadata {
+ pub fn new() -> io::Result<Self> {
+ let _tmp = tempfile::Builder::new()
+ .suffix(".json")
+ .tempfile()?
+ .into_temp_path();
+ let msg = Editmsg::new(&_tmp)?;
+
+ Ok(Self { _tmp, msg })
+ }
+
+ // TODO: explainers, edit errors
+ pub fn edit<T>(self, template: T) -> io::Result<Option<T>>
+ where
+ T: serde::Serialize + serde::de::DeserializeOwned,
+ {
+ let txt = self.msg.edit(|buf| {
+ serde_json::to_writer_pretty(buf, &template)?;
+
+ Ok(())
+ })?;
+
+ Ok(txt.as_deref().map(serde_json::from_str).transpose()?)
+ }
+}
+
+struct Editmsg {
+ file: LockedFile,
+}
+
+impl Editmsg {
+ fn new<P: Into<PathBuf>>(path: P) -> io::Result<Self> {
+ LockedFile::in_place(path, true, 0o644).map(|file| Self { file })
+ }
+
+ fn edit<F>(mut self, pre_fill: F) -> io::Result<Option<String>>
+ where
+ F: FnOnce(&mut LockedFile) -> io::Result<()>,
+ {
+ pre_fill(&mut self.file)?;
+ Command::new(editor())
+ .arg(self.file.edit_path())
+ .spawn()?
+ .wait()?;
+ self.file.reopen()?;
+ let mut msg = String::new();
+ for line in io::BufReader::new(self.file).lines() {
+ let line = line?;
+ if line == SCISSORS {
+ break;
+ }
+ if line.starts_with('#') {
+ continue;
+ }
+
+ msg.push_str(&line);
+ msg.push('\n');
+ }
+ let len = msg.trim_end().len();
+ msg.truncate(len);
+
+ Ok(if msg.is_empty() { None } else { Some(msg) })
+ }
+}
+
+fn editor() -> OsString {
+ #[cfg(windows)]
+ const DEFAULT_EDITOR: &str = "notepad.exe";
+ #[cfg(not(windows))]
+ const DEFAULT_EDITOR: &str = "vi";
+
+ if let Some(exe) = env::var_os("VISUAL") {
+ return exe;
+ }
+ if let Some(exe) = env::var_os("EDITOR") {
+ return exe;
+ }
+ DEFAULT_EDITOR.into()
+}
diff --git a/src/cmd/ui/output.rs b/src/cmd/ui/output.rs
new file mode 100644
index 0000000..f1ad598
--- /dev/null
+++ b/src/cmd/ui/output.rs
@@ -0,0 +1,44 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+pub use log::{
+ debug,
+ error,
+ info,
+ warn,
+};
+
+pub struct Output;
+
+impl log::Log for Output {
+ fn enabled(&self, metadata: &log::Metadata) -> bool {
+ metadata.level() <= log::max_level()
+ }
+
+ fn log(&self, record: &log::Record) {
+ let meta = record.metadata();
+ if !self.enabled(meta) {
+ return;
+ }
+ let level = meta.level();
+ let style = {
+ let s = console::Style::new().for_stderr();
+ if level < log::Level::Info
+ && console::user_attended_stderr()
+ && console::colors_enabled_stderr()
+ {
+ match level {
+ log::Level::Error => s.red(),
+ log::Level::Warn => s.yellow(),
+ log::Level::Info | log::Level::Debug | log::Level::Trace => unreachable!(),
+ }
+ } else {
+ s
+ }
+ };
+
+ eprintln!("{}", style.apply_to(record.args()));
+ }
+
+ fn flush(&self) {}
+}
diff --git a/src/cmd/util.rs b/src/cmd/util.rs
new file mode 100644
index 0000000..27654d8
--- /dev/null
+++ b/src/cmd/util.rs
@@ -0,0 +1,4 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+pub mod args;
diff --git a/src/cmd/util/args.rs b/src/cmd/util/args.rs
new file mode 100644
index 0000000..e372c82
--- /dev/null
+++ b/src/cmd/util/args.rs
@@ -0,0 +1,139 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::{
+ fmt,
+ slice,
+ str::FromStr,
+};
+use std::{
+ borrow::Borrow,
+ convert::Infallible,
+ env,
+ path::PathBuf,
+ vec,
+};
+
+pub use crate::git::Refname;
+use crate::{
+ cfg::paths,
+ git,
+};
+
+/// Search path akin to the `PATH` environment variable.
+#[derive(Clone, Debug)]
+pub struct SearchPath(Vec<PathBuf>);
+
+impl SearchPath {
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+}
+
+impl fmt::Display for SearchPath {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ std::env::join_paths(&self.0)
+ .unwrap()
+ .to_string_lossy()
+ .fmt(f)
+ }
+}
+
+impl FromStr for SearchPath {
+ type Err = Infallible;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Ok(Self(env::split_paths(s).collect()))
+ }
+}
+
+impl IntoIterator for SearchPath {
+ type Item = PathBuf;
+ type IntoIter = vec::IntoIter<PathBuf>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a SearchPath {
+ type Item = &'a PathBuf;
+ type IntoIter = slice::Iter<'a, PathBuf>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.iter()
+ }
+}
+
+/// A [`SearchPath`] with a [`Default`] appropriate for `it` identity
+/// repositories.
+#[derive(Clone, Debug)]
+pub struct IdSearchPath(SearchPath);
+
+impl IdSearchPath {
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// Attempt to open each path element as a git repository
+ ///
+ /// The repositories will be opened as bare, even if they aren't. No error
+ /// is returned if a repo could not be opened (e.g. because it is not a git
+ /// repository).
+ pub fn open_git(&self) -> Vec<git2::Repository> {
+ let mut rs = Vec::with_capacity(self.len());
+ for path in self {
+ if let Ok(repo) = git::repo::open_bare(path) {
+ rs.push(repo);
+ }
+ }
+
+ rs
+ }
+}
+
+impl Default for IdSearchPath {
+ fn default() -> Self {
+ Self(SearchPath(vec![paths::ids()]))
+ }
+}
+
+impl fmt::Display for IdSearchPath {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl FromStr for IdSearchPath {
+ type Err = <SearchPath as FromStr>::Err;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ s.parse().map(Self)
+ }
+}
+
+impl IntoIterator for IdSearchPath {
+ type Item = <SearchPath as IntoIterator>::Item;
+ type IntoIter = <SearchPath as IntoIterator>::IntoIter;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a IdSearchPath {
+ type Item = <&'a SearchPath as IntoIterator>::Item;
+ type IntoIter = <&'a SearchPath as IntoIterator>::IntoIter;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.borrow().into_iter()
+ }
+}