diff options
Diffstat (limited to 'src/cmd')
-rw-r--r-- | src/cmd/drop.rs | 205 | ||||
-rw-r--r-- | src/cmd/drop/bundles.rs | 32 | ||||
-rw-r--r-- | src/cmd/drop/bundles/prune.rs | 113 | ||||
-rw-r--r-- | src/cmd/drop/bundles/sync.rs | 276 | ||||
-rw-r--r-- | src/cmd/drop/edit.rs | 368 | ||||
-rw-r--r-- | src/cmd/drop/init.rs | 194 | ||||
-rw-r--r-- | src/cmd/drop/serve.rs | 140 | ||||
-rw-r--r-- | src/cmd/drop/show.rs | 208 | ||||
-rw-r--r-- | src/cmd/drop/snapshot.rs | 20 | ||||
-rw-r--r-- | src/cmd/drop/unbundle.rs | 93 | ||||
-rw-r--r-- | src/cmd/id.rs | 188 | ||||
-rw-r--r-- | src/cmd/id/edit.rs | 209 | ||||
-rw-r--r-- | src/cmd/id/init.rs | 230 | ||||
-rw-r--r-- | src/cmd/id/show.rs | 75 | ||||
-rw-r--r-- | src/cmd/id/sign.rs | 221 | ||||
-rw-r--r-- | src/cmd/mergepoint.rs | 75 | ||||
-rw-r--r-- | src/cmd/patch.rs | 77 | ||||
-rw-r--r-- | src/cmd/patch/create.rs | 483 | ||||
-rw-r--r-- | src/cmd/patch/prepare.rs | 615 | ||||
-rw-r--r-- | src/cmd/topic.rs | 58 | ||||
-rw-r--r-- | src/cmd/topic/comment.rs | 68 | ||||
-rw-r--r-- | src/cmd/topic/ls.rs | 32 | ||||
-rw-r--r-- | src/cmd/topic/show.rs | 34 | ||||
-rw-r--r-- | src/cmd/topic/unbundle.rs | 174 | ||||
-rw-r--r-- | src/cmd/ui.rs | 131 | ||||
-rw-r--r-- | src/cmd/ui/editor.rs | 228 | ||||
-rw-r--r-- | src/cmd/ui/output.rs | 44 | ||||
-rw-r--r-- | src/cmd/util.rs | 4 | ||||
-rw-r--r-- | src/cmd/util/args.rs | 139 |
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, ¬e) + } + + 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: ¬es::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<¬es::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<¬es::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() + } +} |