summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorKim Altintop <kim@eagain.io>2023-01-09 13:18:33 +0100
committerKim Altintop <kim@eagain.io>2023-01-09 13:18:33 +0100
commitd2f423521ec76406944ad83098ec33afe20c692b (patch)
treeafd86bcb088eebdd61ba4e52fa666ff0f41c42a2 /src
This is it
Squashed commit of all the exploration history. Development starts here. Signed-off-by: Kim Altintop <kim@eagain.io>
Diffstat (limited to 'src')
-rw-r--r--src/bin/it.rs181
-rw-r--r--src/bundle.rs114
-rw-r--r--src/bundle/error.rs31
-rw-r--r--src/bundle/fetch.rs130
-rw-r--r--src/bundle/header.rs365
-rw-r--r--src/bundle/list.rs335
-rw-r--r--src/cfg.rs180
-rw-r--r--src/cmd.rs117
-rw-r--r--src/cmd/drop.rs205
-rw-r--r--src/cmd/drop/bundles.rs32
-rw-r--r--src/cmd/drop/bundles/prune.rs113
-rw-r--r--src/cmd/drop/bundles/sync.rs276
-rw-r--r--src/cmd/drop/edit.rs368
-rw-r--r--src/cmd/drop/init.rs194
-rw-r--r--src/cmd/drop/serve.rs140
-rw-r--r--src/cmd/drop/show.rs208
-rw-r--r--src/cmd/drop/snapshot.rs20
-rw-r--r--src/cmd/drop/unbundle.rs93
-rw-r--r--src/cmd/id.rs188
-rw-r--r--src/cmd/id/edit.rs209
-rw-r--r--src/cmd/id/init.rs230
-rw-r--r--src/cmd/id/show.rs75
-rw-r--r--src/cmd/id/sign.rs221
-rw-r--r--src/cmd/mergepoint.rs75
-rw-r--r--src/cmd/patch.rs77
-rw-r--r--src/cmd/patch/create.rs483
-rw-r--r--src/cmd/patch/prepare.rs615
-rw-r--r--src/cmd/topic.rs58
-rw-r--r--src/cmd/topic/comment.rs68
-rw-r--r--src/cmd/topic/ls.rs32
-rw-r--r--src/cmd/topic/show.rs34
-rw-r--r--src/cmd/topic/unbundle.rs174
-rw-r--r--src/cmd/ui.rs131
-rw-r--r--src/cmd/ui/editor.rs228
-rw-r--r--src/cmd/ui/output.rs44
-rw-r--r--src/cmd/util.rs4
-rw-r--r--src/cmd/util/args.rs139
-rw-r--r--src/error.rs12
-rw-r--r--src/fs.rs192
-rw-r--r--src/git.rs111
-rw-r--r--src/git/commit.rs46
-rw-r--r--src/git/config.rs31
-rw-r--r--src/git/refs.rs327
-rw-r--r--src/git/repo.rs93
-rw-r--r--src/git/serde.rs61
-rw-r--r--src/http.rs355
-rw-r--r--src/io.rs146
-rw-r--r--src/iter.rs109
-rw-r--r--src/json.rs49
-rw-r--r--src/json/canonical.rs166
-rw-r--r--src/keys.rs206
-rw-r--r--src/lib.rs33
-rw-r--r--src/metadata.rs749
-rw-r--r--src/metadata/drop.rs274
-rw-r--r--src/metadata/error.rs40
-rw-r--r--src/metadata/git.rs232
-rw-r--r--src/metadata/identity.rs366
-rw-r--r--src/metadata/mirrors.rs95
-rw-r--r--src/patches.rs212
-rw-r--r--src/patches/bundle.rs344
-rw-r--r--src/patches/error.rs29
-rw-r--r--src/patches/iter.rs395
-rw-r--r--src/patches/notes.rs181
-rw-r--r--src/patches/record.rs472
-rw-r--r--src/patches/state.rs231
-rw-r--r--src/patches/submit.rs574
-rw-r--r--src/patches/traits.rs165
-rw-r--r--src/serde.rs28
-rw-r--r--src/ssh.rs5
-rw-r--r--src/ssh/agent.rs279
-rw-r--r--src/str.rs94
71 files changed, 12889 insertions, 0 deletions
diff --git a/src/bin/it.rs b/src/bin/it.rs
new file mode 100644
index 0000000..e6d5d4c
--- /dev/null
+++ b/src/bin/it.rs
@@ -0,0 +1,181 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ io,
+ path::PathBuf,
+};
+
+use clap::ValueHint;
+use clap_complete::Shell;
+
+static OUTPUT: it::Output = it::Output;
+
+fn main() -> it::Result<()> {
+ use clap::Parser as _;
+
+ log::set_logger(&OUTPUT)?;
+ log::set_max_level(
+ std::env::var("RUST_LOG")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(log::LevelFilter::Info),
+ );
+
+ let cli = It::parse();
+ match cli.cmd {
+ Cmd::Cmd(cmd) => cmd
+ .run()
+ .and_then(|o| render(o, cli.compact))
+ .or_else(|e| e.downcast::<it::cmd::Aborted>().map(|_aborted| ())),
+ Cmd::Hidden(cmd) => match cmd {
+ Hidden::Man { out } => hidden::mangen(&out),
+ Hidden::Completions { shell, out } => hidden::completions(shell, out.as_deref()),
+ },
+ }
+}
+
+/// it: zero-g git
+#[derive(Debug, clap::Parser)]
+#[clap(author, version, about, propagate_version = true, max_term_width = 100)]
+struct It {
+ /// Path to the git repository containing the drop state
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ env = "GIT_DIR",
+ default_value_os_t = std::env::current_dir().unwrap(),
+ value_hint = ValueHint::DirPath,
+ global = true,
+ )]
+ git_dir: PathBuf,
+ /// Do not pretty-print the output
+ #[clap(long, value_parser, default_value_t = false, global = true)]
+ compact: bool,
+ #[clap(subcommand)]
+ cmd: Cmd,
+}
+
+fn render(output: it::cmd::Output, compact: bool) -> it::Result<()> {
+ use it::cmd::Output::*;
+
+ let go = |v| {
+ let out = io::stdout();
+ if compact {
+ serde_json::to_writer(out, &v)
+ } else {
+ serde_json::to_writer_pretty(out, &v)
+ }
+ };
+
+ match output {
+ Val(v) => go(v)?,
+ Iter(i) => {
+ for v in i {
+ let v = v?;
+ go(v)?;
+ println!();
+ }
+ },
+ }
+
+ Ok(())
+}
+
+#[derive(Debug, clap::Subcommand)]
+#[allow(clippy::large_enum_variant)]
+enum Cmd {
+ #[clap(flatten)]
+ Cmd(it::Cmd),
+ #[clap(flatten)]
+ Hidden(Hidden),
+}
+
+#[derive(Debug, clap::Subcommand)]
+#[clap(hide = true)]
+enum Hidden {
+ /// Generate man pages
+ #[clap(hide = true)]
+ Man {
+ /// Output to this directory
+ #[clap(
+ value_parser,
+ default_value = "man",
+ value_name = "DIR",
+ value_hint = ValueHint::DirPath,
+ )]
+ out: PathBuf,
+ },
+ /// Generate shell completions
+ #[clap(hide = true)]
+ Completions {
+ /// The shell to generate completions for
+ #[clap(value_parser)]
+ shell: Shell,
+ /// Output file (stdout if not set)
+ #[clap(value_parser, value_name = "FILE", value_hint = ValueHint::FilePath)]
+ out: Option<PathBuf>,
+ },
+}
+
+mod hidden {
+ use std::{
+ fs::File,
+ io,
+ path::Path,
+ };
+
+ use clap::CommandFactory as _;
+ use clap_complete::Shell;
+ use clap_mangen::Man;
+
+ pub fn mangen(out: &Path) -> it::Result<()> {
+ std::fs::create_dir_all(out)?;
+ let it = super::It::command();
+ for cmd in it.get_subcommands() {
+ if cmd.get_name() == "dev" {
+ continue;
+ }
+ for sub in cmd.get_subcommands() {
+ let name = format!("{}-{}-{}", it.get_name(), cmd.get_name(), sub.get_name());
+ let filename = out.join(&name).with_extension("1");
+
+ let the_cmd = sub.clone().name(&name);
+ let man = Man::new(the_cmd)
+ .title(name.to_uppercase())
+ .section("1")
+ .manual("It Manual");
+
+ eprintln!("Generating {}...", filename.display());
+ man.render(
+ &mut File::options()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .open(&filename)?,
+ )?;
+ }
+ }
+
+ Ok(())
+ }
+
+ pub fn completions(shell: Shell, out: Option<&Path>) -> it::Result<()> {
+ match out {
+ Some(path) => {
+ let mut out = File::options()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .open(path)?;
+ clap_complete::generate(shell, &mut super::It::command(), "it", &mut out);
+ },
+ None => {
+ clap_complete::generate(shell, &mut super::It::command(), "it", &mut io::stdout());
+ },
+ }
+
+ Ok(())
+ }
+}
diff --git a/src/bundle.rs b/src/bundle.rs
new file mode 100644
index 0000000..25eafd0
--- /dev/null
+++ b/src/bundle.rs
@@ -0,0 +1,114 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::io;
+
+use log::info;
+use sha2::{
+ Digest,
+ Sha256,
+};
+use url::Url;
+
+use crate::io::{
+ HashWriter,
+ LenWriter,
+};
+
+pub mod error;
+
+mod fetch;
+pub use fetch::{
+ Fetched,
+ Fetcher,
+};
+
+mod header;
+pub use header::{
+ Hash,
+ Header,
+ ObjectFormat,
+ ObjectId,
+ Version,
+};
+
+pub mod list;
+pub use list::{
+ List,
+ Location,
+ Uri,
+};
+
+pub const FILE_EXTENSION: &str = "bundle";
+pub const DOT_FILE_EXTENSION: &str = ".bundle";
+
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
+pub struct Info {
+ pub len: u64,
+ pub hash: Hash,
+ #[serde(with = "hex::serde")]
+ pub checksum: [u8; 32],
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub uris: Vec<Url>,
+}
+
+#[derive(Clone, Copy)]
+pub struct Expect<'a> {
+ pub len: u64,
+ pub hash: &'a Hash,
+ pub checksum: Option<&'a [u8]>,
+}
+
+impl<'a> From<&'a Info> for Expect<'a> {
+ fn from(
+ Info {
+ len,
+ hash,
+ checksum,
+ ..
+ }: &'a Info,
+ ) -> Self {
+ Self {
+ len: *len,
+ hash,
+ checksum: Some(checksum),
+ }
+ }
+}
+
+pub fn create<W>(mut out: W, repo: &git2::Repository, header: &Header) -> crate::Result<Info>
+where
+ W: io::Write,
+{
+ let mut hasher = HashWriter::new(Sha256::new(), &mut out);
+ let mut writer = LenWriter::new(&mut hasher);
+ let mut pack = {
+ let mut pack = repo.packbuilder()?;
+ let mut walk = repo.revwalk()?;
+ for pre in &header.prerequisites {
+ walk.hide(pre.try_into()?)?;
+ }
+ for inc in header.references.values() {
+ walk.push(inc.try_into()?)?;
+ }
+ pack.insert_walk(&mut walk)?;
+ pack
+ };
+ header.to_writer(&mut writer)?;
+
+ info!("Packing objects...");
+ pack.foreach(|chunk| io::Write::write_all(&mut writer, chunk).is_ok())?;
+
+ let len = writer.bytes_written();
+ let hash = header.hash();
+ let checksum = hasher.hash().into();
+
+ info!("Created patch bundle {hash}");
+
+ Ok(Info {
+ len,
+ hash,
+ checksum,
+ uris: vec![],
+ })
+}
diff --git a/src/bundle/error.rs b/src/bundle/error.rs
new file mode 100644
index 0000000..41529c2
--- /dev/null
+++ b/src/bundle/error.rs
@@ -0,0 +1,31 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use thiserror::Error;
+
+use super::{
+ ObjectFormat,
+ ObjectId,
+};
+use crate::git::refs;
+
+#[derive(Debug, Error)]
+pub enum Header {
+ #[error("invalid header: {0}")]
+ Format(&'static str),
+
+ #[error("unrecognised header {0}")]
+ UnrecognisedHeader(String),
+
+ #[error("object id {oid} not valid for object-format {fmt}")]
+ ObjectFormat { fmt: ObjectFormat, oid: ObjectId },
+
+ #[error("invalid reference name")]
+ Refname(#[from] refs::error::RefFormat),
+
+ #[error("invalid hex oid")]
+ Oid(#[from] hex::FromHexError),
+
+ #[error(transparent)]
+ Io(#[from] std::io::Error),
+}
diff --git a/src/bundle/fetch.rs b/src/bundle/fetch.rs
new file mode 100644
index 0000000..4e58000
--- /dev/null
+++ b/src/bundle/fetch.rs
@@ -0,0 +1,130 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ fs,
+ io::{
+ self,
+ Read,
+ Seek,
+ SeekFrom,
+ Write,
+ },
+ path::{
+ Path,
+ PathBuf,
+ },
+};
+
+use anyhow::ensure;
+use either::Either::{
+ self,
+ Left,
+ Right,
+};
+use sha2::{
+ Digest,
+ Sha256,
+};
+use tempfile::NamedTempFile;
+use url::Url;
+
+use super::{
+ header,
+ Expect,
+ Header,
+};
+use crate::{
+ bundle,
+ fs::LockedFile,
+ git,
+ io::HashWriter,
+};
+
+const MAX_BUNDLE_URIS_BYTES: u64 = 50_000;
+
+pub struct Fetched {
+ path: PathBuf,
+ info: bundle::Info,
+}
+
+impl Fetched {
+ pub fn into_inner(self) -> (PathBuf, bundle::Info) {
+ (self.path, self.info)
+ }
+}
+
+pub struct Fetcher {
+ agent: ureq::Agent,
+}
+
+impl Default for Fetcher {
+ fn default() -> Self {
+ Self {
+ agent: ureq::agent(),
+ }
+ }
+}
+
+impl Fetcher {
+ pub fn fetch(
+ &self,
+ url: &Url,
+ out_dir: &Path,
+ expect: Expect,
+ ) -> crate::Result<Either<bundle::List, Fetched>> {
+ let resp = self.agent.request_url("GET", url).call()?;
+ let mut body = resp.into_reader();
+
+ let mut buf = [0; 16];
+ body.read_exact(&mut buf)?;
+ let is_bundle = buf.starts_with(header::SIGNATURE_V2.as_bytes())
+ || buf.starts_with(header::SIGNATURE_V3.as_bytes());
+ if is_bundle {
+ ensure!(
+ matches!(buf.last(), Some(b'\n')),
+ "malformed bundle header: trailing data"
+ )
+ }
+
+ if is_bundle {
+ let mut path = out_dir.join(expect.hash.to_string());
+ path.set_extension(bundle::FILE_EXTENSION);
+
+ let mut lck = {
+ fs::create_dir_all(out_dir)?;
+ LockedFile::atomic(&path, true, LockedFile::DEFAULT_PERMISSIONS)?
+ };
+
+ let mut out = HashWriter::new(Sha256::new(), &mut lck);
+ out.write_all(&buf)?;
+
+ let len = buf.len() as u64 + io::copy(&mut body.take(expect.len), &mut out)?;
+ let checksum = out.hash().into();
+ if let Some(chk) = expect.checksum {
+ ensure!(chk == checksum, "checksum mismatch");
+ }
+ lck.seek(SeekFrom::Start(0))?;
+ let header = Header::from_reader(&mut lck)?;
+ let hash = header.hash();
+
+ lck.persist()?;
+
+ let info = bundle::Info {
+ len,
+ hash,
+ checksum,
+ uris: vec![url.clone()],
+ };
+ Ok(Right(Fetched { path, info }))
+ } else {
+ let mut tmp = NamedTempFile::new()?;
+ tmp.write_all(&buf)?;
+ io::copy(&mut body.take(MAX_BUNDLE_URIS_BYTES), &mut tmp)?;
+ let cfg = git::config::Snapshot::try_from(git2::Config::open(tmp.path())?)?;
+ let list = bundle::List::from_config(cfg)?;
+
+ Ok(Left(list))
+ }
+ }
+}
diff --git a/src/bundle/header.rs b/src/bundle/header.rs
new file mode 100644
index 0000000..6f3dfe3
--- /dev/null
+++ b/src/bundle/header.rs
@@ -0,0 +1,365 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::fmt;
+use std::{
+ collections::{
+ BTreeMap,
+ BTreeSet,
+ },
+ io,
+ ops::Deref,
+ str::FromStr,
+};
+
+use hex::{
+ FromHex,
+ FromHexError,
+};
+use refs::Refname;
+use sha2::{
+ Digest,
+ Sha256,
+};
+
+use super::error;
+use crate::{
+ git::refs,
+ io::Lines,
+};
+
+pub const SIGNATURE_V2: &str = "# v2 git bundle";
+pub const SIGNATURE_V3: &str = "# v3 git bundle";
+
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Version {
+ V2,
+ V3,
+}
+
+impl Default for Version {
+ fn default() -> Self {
+ Self::V2
+ }
+}
+
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum ObjectFormat {
+ Sha1,
+ Sha256,
+}
+
+impl Default for ObjectFormat {
+ fn default() -> Self {
+ Self::Sha1
+ }
+}
+
+impl fmt::Display for ObjectFormat {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ Self::Sha1 => "sha1",
+ Self::Sha256 => "sha256",
+ })
+ }
+}
+
+#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
+#[serde(untagged)]
+pub enum ObjectId {
+ Sha1(#[serde(with = "hex::serde")] [u8; 20]),
+ Sha2(#[serde(with = "hex::serde")] [u8; 32]),
+}
+
+impl ObjectId {
+ pub fn as_bytes(&self) -> &[u8] {
+ self.as_ref()
+ }
+}
+
+impl AsRef<[u8]> for ObjectId {
+ fn as_ref(&self) -> &[u8] {
+ match self {
+ Self::Sha1(b) => &b[..],
+ Self::Sha2(b) => &b[..],
+ }
+ }
+}
+
+impl fmt::Display for ObjectId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&hex::encode(self))
+ }
+}
+
+impl fmt::Debug for ObjectId {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match self {
+ Self::Sha1(x) => f.debug_tuple("Sha1").field(&hex::encode(x)).finish(),
+ Self::Sha2(x) => f.debug_tuple("Sha2").field(&hex::encode(x)).finish(),
+ }
+ }
+}
+
+impl FromHex for ObjectId {
+ type Error = hex::FromHexError;
+
+ #[inline]
+ fn from_hex<T: AsRef<[u8]>>(hex: T) -> Result<Self, Self::Error> {
+ match hex.as_ref().len() {
+ 40 => Ok(Self::Sha1(<[u8; 20]>::from_hex(hex)?)),
+ 64 => Ok(Self::Sha2(<[u8; 32]>::from_hex(hex)?)),
+ _ => Err(hex::FromHexError::InvalidStringLength),
+ }
+ }
+}
+
+impl From<&git2::Oid> for ObjectId {
+ fn from(oid: &git2::Oid) -> Self {
+ let bs = oid.as_bytes();
+ match bs.len() {
+ 20 => Self::Sha1(bs.try_into().unwrap()),
+ 32 => Self::Sha2(bs.try_into().unwrap()),
+ x => unreachable!("oid with strange hash size: {}", x),
+ }
+ }
+}
+
+impl TryFrom<&ObjectId> for git2::Oid {
+ type Error = git2::Error;
+
+ fn try_from(oid: &ObjectId) -> Result<Self, Self::Error> {
+ match oid {
+ ObjectId::Sha1(hash) => Self::from_bytes(hash),
+ ObjectId::Sha2(_) => Err(git2::Error::new(
+ git2::ErrorCode::Invalid,
+ git2::ErrorClass::Sha1,
+ "sha2 oids not yet supported",
+ )),
+ }
+ }
+}
+
+#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Header {
+ pub version: Version,
+ pub object_format: ObjectFormat,
+ pub prerequisites: BTreeSet<ObjectId>,
+ pub references: BTreeMap<Refname, ObjectId>,
+}
+
+impl Header {
+ /// Parse a [`Header`] from an IO stream.
+ ///
+ /// The stream will be buffered internally, and its position set to the
+ /// start of the packfile section.
+ pub fn from_reader<R>(mut io: R) -> Result<Self, error::Header>
+ where
+ R: io::Read + io::Seek,
+ {
+ use hex::FromHex as _;
+
+ let mut lines = Lines::new(io::BufReader::new(&mut io)).until_blank();
+
+ let mut version: Option<Version> = None;
+ let mut object_format: Option<ObjectFormat> = None;
+ let mut prerequisites = BTreeSet::new();
+ let mut references = BTreeMap::new();
+
+ match lines
+ .next()
+ .ok_or(error::Header::Format("empty input"))??
+ .as_str()
+ {
+ SIGNATURE_V2 => {
+ version = Some(Version::V2);
+ object_format = Some(ObjectFormat::Sha1);
+ Ok(())
+ },
+
+ SIGNATURE_V3 => {
+ version = Some(Version::V2);
+ Ok(())
+ },
+
+ _ => Err(error::Header::Format("invalid signature")),
+ }?;
+
+ if let Some(Version::V3) = version {
+ for capability in lines.by_ref() {
+ let capability = capability?;
+
+ if !capability.starts_with('@') {
+ return Err(error::Header::Format("expected capabilities"));
+ }
+
+ if capability.starts_with("@filter") {
+ return Err(error::Header::Format("object filters are not supported"));
+ }
+
+ match capability.strip_prefix("@object-format=") {
+ Some("sha1") => {
+ object_format = Some(ObjectFormat::Sha1);
+ },
+
+ Some("sha256") => {
+ object_format = Some(ObjectFormat::Sha256);
+ },
+
+ _ => return Err(error::Header::Format("unrecognised capability")),
+ }
+
+ if object_format.is_some() {
+ break;
+ }
+ }
+ }
+
+ let version = version.unwrap();
+ let object_format = object_format.ok_or(error::Header::Format("missing object-format"))?;
+
+ for tip in lines.by_ref() {
+ let mut tip = tip?;
+ let oid_off = usize::from(tip.starts_with('-'));
+ let oid_hexsz = match object_format {
+ ObjectFormat::Sha1 => 40,
+ ObjectFormat::Sha256 => 64,
+ };
+
+ let oid = ObjectId::from_hex(&tip[oid_off..oid_hexsz + oid_off])?;
+ if matches!(
+ (&object_format, &oid),
+ (ObjectFormat::Sha1, ObjectId::Sha2(_)) | (ObjectFormat::Sha256, ObjectId::Sha1(_))
+ ) {
+ return Err(error::Header::ObjectFormat {
+ fmt: object_format,
+ oid,
+ });
+ }
+ if !matches!(tip.chars().nth(oid_off + oid_hexsz), None | Some(' ')) {
+ return Err(error::Header::UnrecognisedHeader(tip));
+ }
+
+ if oid_off > 0 {
+ prerequisites.insert(oid);
+ } else {
+ let refname = tip.split_off(oid_off + oid_hexsz + 1);
+ if !refname.starts_with("refs/") {
+ return Err(error::Header::Format("shorthand refname"));
+ }
+ if references.insert(refname.parse()?, oid).is_some() {
+ return Err(error::Header::Format("duplicate refname"));
+ }
+ }
+ }
+
+ if references.is_empty() {
+ return Err(error::Header::Format("empty references"));
+ }
+
+ let pos = io::Seek::stream_position(&mut lines)?;
+ drop(lines);
+ io.seek(io::SeekFrom::Start(pos))?;
+
+ Ok(Header {
+ version,
+ object_format,
+ prerequisites,
+ references,
+ })
+ }
+
+ pub fn to_writer<W>(&self, mut io: W) -> io::Result<()>
+ where
+ W: io::Write,
+ {
+ match self.version {
+ Version::V2 => writeln!(&mut io, "{}", SIGNATURE_V2)?,
+ Version::V3 => {
+ writeln!(&mut io, "{}", SIGNATURE_V3)?;
+ match self.object_format {
+ ObjectFormat::Sha1 => writeln!(&mut io, "@object-format=sha1")?,
+ ObjectFormat::Sha256 => writeln!(&mut io, "@object-format=sha256")?,
+ }
+ },
+ }
+ for pre in &self.prerequisites {
+ writeln!(&mut io, "-{}", pre)?;
+ }
+ for (name, oid) in &self.references {
+ writeln!(&mut io, "{} {}", oid, name)?;
+ }
+
+ writeln!(&mut io)
+ }
+
+ pub fn add_prerequisite<O>(&mut self, oid: O) -> bool
+ where
+ O: Into<ObjectId>,
+ {
+ self.prerequisites.insert(oid.into())
+ }
+
+ pub fn add_reference<O>(&mut self, name: Refname, oid: O) -> Option<ObjectId>
+ where
+ O: Into<ObjectId>,
+ {
+ self.references.insert(name, oid.into())
+ }
+
+ pub fn hash(&self) -> Hash {
+ let mut ids: BTreeSet<&ObjectId> = BTreeSet::new();
+ ids.extend(self.prerequisites.iter());
+ ids.extend(self.references.values());
+
+ let mut sha = Sha256::new();
+ for id in ids {
+ sha.update(id);
+ }
+ Hash(sha.finalize().into())
+ }
+}
+
+#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)]
+pub struct Hash(#[serde(with = "hex::serde")] [u8; 32]);
+
+impl Hash {
+ pub fn as_bytes(&self) -> &[u8] {
+ self.deref()
+ }
+
+ pub fn is_valid(hex: &str) -> bool {
+ Self::from_str(hex).is_ok()
+ }
+}
+
+impl Deref for Hash {
+ type Target = [u8; 32];
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl fmt::Display for Hash {
+ fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+ f.write_str(&hex::encode(self.0))
+ }
+}
+
+impl fmt::Debug for Hash {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&hex::encode(self.0))
+ }
+}
+
+impl FromStr for Hash {
+ type Err = FromHexError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ <[u8; 32]>::from_hex(s).map(Self)
+ }
+}
diff --git a/src/bundle/list.rs b/src/bundle/list.rs
new file mode 100644
index 0000000..21753fa
--- /dev/null
+++ b/src/bundle/list.rs
@@ -0,0 +1,335 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+//! Bundle Lists in git config format, as per [`bundle-uri`].
+//!
+//! [`bundle-uri`]: https://git.kernel.org/pub/scm/git/git.git/tree/Documentation/technical/bundle-uri.txt
+
+use std::{
+ borrow::Cow,
+ cmp::Ordering,
+ collections::HashMap,
+ fmt,
+ io,
+ str::FromStr,
+ time::{
+ SystemTime,
+ UNIX_EPOCH,
+ },
+};
+
+use anyhow::anyhow;
+use once_cell::sync::Lazy;
+use sha2::{
+ Digest,
+ Sha256,
+};
+use url::Url;
+
+use crate::git::{
+ self,
+ if_not_found_none,
+};
+
+pub const FILE_EXTENSION: &str = "uris";
+pub const DOT_FILE_EXTENSION: &str = ".uris";
+
+#[derive(Clone, Copy, Debug)]
+pub enum Mode {
+ All,
+ Any,
+}
+
+impl Mode {
+ pub fn as_str(&self) -> &str {
+ match self {
+ Self::All => "all",
+ Self::Any => "any",
+ }
+ }
+}
+
+impl fmt::Display for Mode {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+impl FromStr for Mode {
+ type Err = crate::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "all" => Ok(Self::All),
+ "any" => Ok(Self::Any),
+ x => Err(anyhow!("unknown bundle list mode: {x}")),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub enum Uri {
+ Absolute(Url),
+ Relative(String),
+}
+
+impl Uri {
+ pub fn as_str(&self) -> &str {
+ match self {
+ Self::Absolute(url) => url.as_str(),
+ Self::Relative(path) => path.as_str(),
+ }
+ }
+
+ pub fn abs(&self, base: &Url) -> Result<Cow<Url>, url::ParseError> {
+ match self {
+ Self::Absolute(url) => Ok(Cow::Borrowed(url)),
+ Self::Relative(path) => base.join(path).map(Cow::Owned),
+ }
+ }
+}
+
+impl From<Url> for Uri {
+ fn from(url: Url) -> Self {
+ Self::Absolute(url)
+ }
+}
+
+impl FromStr for Uri {
+ type Err = url::ParseError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ static DUMMY_BASE: Lazy<Url> =
+ Lazy::new(|| Url::parse("https://bundles.example.com").unwrap());
+
+ Url::parse(s).map(Self::Absolute).or_else(|e| match e {
+ url::ParseError::RelativeUrlWithoutBase => {
+ let url = Url::options().base_url(Some(&DUMMY_BASE)).parse(s)?;
+
+ let path = if s.starts_with('/') {
+ url.path()
+ } else {
+ url.path().trim_start_matches('/')
+ };
+
+ Ok(Self::Relative(path.to_owned()))
+ },
+ other => Err(other),
+ })
+ }
+}
+
+#[derive(Debug)]
+pub struct Location {
+ pub id: String,
+ pub uri: Uri,
+ pub filter: Option<String>,
+ pub creation_token: Option<u64>,
+ pub location: Option<String>,
+}
+
+impl Location {
+ pub fn new(id: String, uri: Uri) -> Self {
+ Self {
+ id,
+ uri,
+ filter: None,
+ creation_token: None,
+ location: None,
+ }
+ }
+
+ pub fn to_config(&self, cfg: &mut git2::Config) -> crate::Result<()> {
+ let section = format!("bundle.{}", self.id);
+
+ cfg.set_str(&format!("{section}.uri"), self.uri.as_str())?;
+ if let Some(filter) = self.filter.as_deref() {
+ cfg.set_str(&format!("{section}.filter"), filter)?;
+ }
+ if let Some(token) = &self.creation_token {
+ cfg.set_str(&format!("{section}.creationToken"), &token.to_string())?;
+ }
+ if let Some(loc) = self.location.as_deref() {
+ cfg.set_str(&format!("{section}.location"), loc)?;
+ }
+
+ Ok(())
+ }
+
+ pub fn to_writer<W: io::Write>(&self, mut out: W) -> io::Result<()> {
+ writeln!(&mut out, "[bundle \"{}\"]", self.id)?;
+ writeln!(&mut out, "\turi = {}", self.uri.as_str())?;
+ if let Some(filter) = self.filter.as_deref() {
+ writeln!(&mut out, "\tfilter = {}", filter)?;
+ }
+ if let Some(token) = &self.creation_token {
+ writeln!(&mut out, "\tcreationToken = {}", token)?;
+ }
+ if let Some(loc) = self.location.as_deref() {
+ writeln!(&mut out, "\tlocation = {}", loc)?;
+ }
+
+ Ok(())
+ }
+}
+
+impl From<Url> for Location {
+ fn from(url: Url) -> Self {
+ let id = hex::encode(Sha256::digest(url.as_str()));
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("backwards system clock")
+ .as_secs();
+ Self {
+ id,
+ uri: url.into(),
+ filter: None,
+ creation_token: Some(now),
+ location: None,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct List {
+ pub mode: Mode,
+ pub heuristic: Option<String>,
+ pub bundles: Vec<Location>,
+}
+
+impl List {
+ pub fn any() -> Self {
+ Self {
+ mode: Mode::Any,
+ heuristic: Some("creationToken".into()),
+ bundles: Vec::new(),
+ }
+ }
+
+ /// Parse a bundle list from a [`git2::Config`]
+ ///
+ /// The config is expected to contain the list config keys `bundle.mode` and
+ /// optionally `bundle.heuristic`. `bundle.version` is currently ignored.
+ ///
+ /// A bundle [`Location`] is yielded if at least `bundle.<id>.uri` is set
+ /// and a valid [`Url`]. The `base` [`Url`] must be provided to resolve
+ /// relative uris in the file.
+ ///
+ /// The [`Location`] list is sorted by creation token in descending order
+ /// (entries without a token sort last). The sort is unstable.
+ pub fn from_config(cfg: git::config::Snapshot) -> crate::Result<Self> {
+ // nb. ignoring version
+ let mode = cfg.get_str("bundle.mode")?.parse()?;
+ let heuristic = if_not_found_none(cfg.get_string("bundle.heuristic"))?;
+
+ #[derive(Default)]
+ struct Info {
+ uri: Option<Uri>,
+ filter: Option<String>,
+ creation_token: Option<u64>,
+ location: Option<String>,
+ }
+
+ let mut bundles: HashMap<String, Info> = HashMap::new();
+ let mut iter = cfg.entries(Some("bundle\\.[^.]+\\.[^.]+$"))?;
+ while let Some(entry) = iter.next() {
+ let entry = entry?;
+ if let Some(("bundle", id, key)) = entry
+ .name()
+ .and_then(|name| name.split_once('.'))
+ .and_then(|(a, b)| b.split_once('.').map(|(c, d)| (a, c, d)))
+ {
+ let value = entry
+ .value()
+ .ok_or_else(|| anyhow!("value for bundle.{id}.{key} not utf8"))?;
+ let info = bundles.entry(id.to_owned()).or_default();
+ match key {
+ "uri" => {
+ let uri = value.parse()?;
+ info.uri = Some(uri);
+ },
+
+ "filter" => {
+ info.filter = Some(value.to_owned());
+ },
+
+ "creationToken" | "creationtoken" => {
+ let token = value.parse()?;
+ info.creation_token = Some(token);
+ },
+
+ "location" => {
+ info.location = Some(value.to_owned());
+ },
+
+ _ => {},
+ }
+ }
+ }
+ let mut bundles = bundles
+ .into_iter()
+ .filter_map(|(id, info)| {
+ info.uri.map(|uri| Location {
+ id,
+ uri,
+ filter: info.filter,
+ creation_token: info.creation_token,
+ location: info.location,
+ })
+ })
+ .collect::<Vec<_>>();
+ bundles.sort_unstable_by(|a, b| match (&a.creation_token, &b.creation_token) {
+ (Some(x), Some(y)) => y.cmp(x),
+ (Some(_), None) => Ordering::Less,
+ (None, Some(_)) => Ordering::Greater,
+ (None, None) => Ordering::Equal,
+ });
+
+ Ok(Self {
+ mode,
+ heuristic,
+ bundles,
+ })
+ }
+
+ pub fn to_config(&self, cfg: &mut git2::Config) -> crate::Result<()> {
+ cfg.set_i32("bundle.version", 1)?;
+ cfg.set_str("bundle.mode", self.mode.as_str())?;
+ if let Some(heuristic) = self.heuristic.as_deref() {
+ cfg.set_str("bundle.heuristic", heuristic)?;
+ }
+ self.bundles.iter().try_for_each(|loc| loc.to_config(cfg))?;
+
+ Ok(())
+ }
+
+ pub fn to_writer<W: io::Write>(&self, mut out: W) -> io::Result<()> {
+ writeln!(&mut out, "[bundle]")?;
+ writeln!(&mut out, "\tversion = 1")?;
+ writeln!(&mut out, "\tmode = {}", self.mode)?;
+ if let Some(heuristic) = self.heuristic.as_deref() {
+ writeln!(&mut out, "\theuristic = {}", heuristic)?;
+ }
+ for loc in &self.bundles {
+ writeln!(&mut out)?;
+ loc.to_writer(&mut out)?;
+ }
+
+ Ok(())
+ }
+
+ pub fn to_str(&self) -> String {
+ let mut buf = Vec::new();
+ self.to_writer(&mut buf).unwrap();
+ unsafe { String::from_utf8_unchecked(buf) }
+ }
+}
+
+impl Extend<Location> for List {
+ fn extend<T>(&mut self, iter: T)
+ where
+ T: IntoIterator<Item = Location>,
+ {
+ self.bundles.extend(iter)
+ }
+}
diff --git a/src/cfg.rs b/src/cfg.rs
new file mode 100644
index 0000000..b6a74da
--- /dev/null
+++ b/src/cfg.rs
@@ -0,0 +1,180 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+pub mod paths {
+ use directories::ProjectDirs;
+ use std::path::{
+ Path,
+ PathBuf,
+ };
+
+ pub fn ids() -> PathBuf {
+ project_dirs().data_dir().join("ids")
+ }
+
+ /// Default path where to store bundles.
+ ///
+ /// This is a relative path, to be treated as relative to GIT_DIR.
+ pub fn bundles() -> &'static Path {
+ Path::new("it/bundles")
+ }
+
+ fn project_dirs() -> ProjectDirs {
+ ProjectDirs::from("io", "eagain", "it").expect("no valid $HOME")
+ }
+}
+
+pub mod git {
+ use std::path::Path;
+
+ use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+ };
+ use zeroize::Zeroizing;
+
+ use crate::{
+ git::{
+ self,
+ if_not_found_none,
+ Refname,
+ },
+ keys::{
+ Agent,
+ Signer,
+ },
+ metadata::IdentityId,
+ ssh::{
+ self,
+ agent,
+ },
+ };
+
+ /// Last resort to override the signing key, if neither [`USER_SIGNING_KEY`]
+ /// nor [`SSH_KEY_COMMAND`] will cut it.
+ pub const IT_SIGNING_KEY: &str = "it.signingKey";
+ /// The default `it` identity to use.
+ pub const IT_ID: &str = "it.id";
+ /// Command to dynamically set the signing key, see
+ /// [`gpg.ssh.defaultKeyCommand`]
+ ///
+ /// [`gpg.ssh.defaultKeyCommand`]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgsshdefaultKeyCommand
+ pub const SSH_KEY_COMMAND: &str = "gpg.ssh.defaultKeyCommand";
+ /// The key to sign git and it objects with, see [`user.signingKey`]
+ ///
+ /// [`user.signingKey`]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-usersigningKey
+ pub const USER_SIGNING_KEY: &str = "user.signingKey";
+ /// The default branch name, see [`init.defaultBranch`]
+ ///
+ /// If not set, the default branch is "master".
+ ///
+ /// [`init.defaultBranch`]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-initdefaultBranch
+ pub const DEFAULT_BRANCH: &str = "init.defaultBranch";
+
+ #[allow(clippy::large_enum_variant)]
+ pub enum Key {
+ Secret(ssh::PrivateKey),
+ Public(ssh::PublicKey),
+ }
+
+ impl Key {
+ pub fn public(&self) -> &ssh::PublicKey {
+ match self {
+ Self::Secret(sk) => sk.public_key(),
+ Self::Public(pk) => pk,
+ }
+ }
+ }
+
+ pub fn signing_key(c: &git2::Config) -> crate::Result<Option<Key>> {
+ match if_not_found_none(c.get_string(IT_SIGNING_KEY))? {
+ Some(v) => ssh_signing_key_from_config_value(v).map(Some),
+ None => ssh_signing_key(c)
+ .transpose()
+ .or_else(|| ssh_key_command(c).transpose())
+ .transpose(),
+ }
+ }
+
+ pub fn signer<F>(c: &git2::Config, askpass: F) -> crate::Result<Box<dyn Signer>>
+ where
+ F: Fn(&str) -> crate::Result<Zeroizing<Vec<u8>>>,
+ {
+ let key = signing_key(c)?.ok_or_else(|| anyhow!("no signing key in git config"))?;
+ match key {
+ Key::Public(pk) => {
+ let client = agent::Client::from_env()?;
+ Ok(Box::new(Agent::new(client, pk.into())))
+ },
+ Key::Secret(sk) => {
+ if sk.is_encrypted() {
+ let prompt = format!(
+ "`it` wants to use the key {}. Please provide a passphrase to decrypt it",
+ sk.public_key().to_openssh()?
+ );
+ for _ in 0..3 {
+ let pass = askpass(&prompt)?;
+ if let Ok(key) = sk.decrypt(pass) {
+ return Ok(Box::new(key));
+ }
+ }
+ bail!("unable to decrypt secret key");
+ } else {
+ Ok(Box::new(sk))
+ }
+ },
+ }
+ }
+
+ pub fn identity(c: &git2::Config) -> crate::Result<Option<IdentityId>> {
+ if_not_found_none(c.get_string(IT_ID))?
+ .map(IdentityId::try_from)
+ .transpose()
+ .map_err(Into::into)
+ }
+
+ pub fn ssh_signing_key(cfg: &git2::Config) -> crate::Result<Option<Key>> {
+ if_not_found_none(cfg.get_string(USER_SIGNING_KEY))?
+ .map(ssh_signing_key_from_config_value)
+ .transpose()
+ }
+
+ pub(crate) fn ssh_signing_key_from_config_value<V: AsRef<str>>(v: V) -> crate::Result<Key> {
+ match v.as_ref().strip_prefix("key::") {
+ Some(lit) => {
+ let key = ssh::PublicKey::from_openssh(lit)?;
+ Ok(Key::Public(key))
+ },
+ None => {
+ let path = Path::new(v.as_ref());
+ ensure!(
+ path.exists(),
+ "{} is not a valid path to an SSH private key",
+ path.display()
+ );
+ let key = ssh::PrivateKey::read_openssh_file(path)?;
+ Ok(Key::Secret(key))
+ },
+ }
+ }
+
+ pub fn ssh_key_command(cfg: &git2::Config) -> crate::Result<Option<Key>> {
+ let out = git::config_command(cfg, SSH_KEY_COMMAND)?;
+ let key = out
+ .as_deref()
+ .map(ssh::PublicKey::from_openssh)
+ .transpose()?
+ .map(Key::Public);
+
+ Ok(key)
+ }
+
+ pub fn default_branch(cfg: &git2::Config) -> crate::Result<Refname> {
+ if_not_found_none(cfg.get_string(DEFAULT_BRANCH))?
+ .unwrap_or_else(|| String::from("master"))
+ .try_into()
+ .map_err(Into::into)
+ }
+}
+pub use git::signer;
diff --git a/src/cmd.rs b/src/cmd.rs
new file mode 100644
index 0000000..85669f9
--- /dev/null
+++ b/src/cmd.rs
@@ -0,0 +1,117 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::metadata::git::{
+ find_parent,
+ FromGit,
+ GitAlternates,
+ GitDrop,
+ GitIdentity,
+ GitMirrors,
+};
+
+mod util;
+use util::args;
+
+pub mod drop;
+pub mod id;
+pub mod mergepoint;
+pub mod patch;
+pub mod topic;
+pub mod ui;
+
+pub use crate::{
+ Error,
+ Result,
+};
+
+/// Error indicating that the command was cancelled at the user's request, eg.
+/// by pressing ESC in an interactive prompt.
+///
+/// By means of [`anyhow::Error::downcast`], this allows for exiting the program
+/// with a zero exit status, even though the invocation returned an `Err`.
+#[derive(Debug, thiserror::Error)]
+#[error("command aborted")]
+pub struct Aborted;
+
+/// Shortcut to return early from a command with an [`Aborted`] error.
+macro_rules! abort {
+ () => {
+ return Err(crate::Error::from(Aborted))
+ };
+}
+pub(crate) use abort;
+
+pub enum Output {
+ Val(Box<dyn erased_serde::Serialize>),
+ Iter(Box<dyn Iterator<Item = Result<Box<dyn erased_serde::Serialize>>>>),
+}
+
+impl Output {
+ pub fn val<T>(v: T) -> Self
+ where
+ T: serde::Serialize + 'static,
+ {
+ Self::Val(Box::new(v))
+ }
+
+ pub fn iter<T, U>(v: T) -> Self
+ where
+ T: IntoIterator<Item = Result<U>> + 'static,
+ U: serde::Serialize + 'static,
+ {
+ let iter = v
+ .into_iter()
+ .map(|x| x.map(|i| Box::new(i) as Box<dyn erased_serde::Serialize>));
+
+ Self::Iter(Box::new(iter))
+ }
+}
+
+trait IntoOutput {
+ fn into_output(self) -> Output;
+}
+
+impl<T> IntoOutput for T
+where
+ T: serde::Serialize + 'static,
+{
+ fn into_output(self) -> Output {
+ Output::Val(Box::new(self))
+ }
+}
+
+#[derive(Debug, clap::Subcommand)]
+pub enum Cmd {
+ /// Drop management
+ #[clap(subcommand)]
+ Drop(drop::Cmd),
+
+ /// Identity management
+ #[clap(subcommand)]
+ Id(id::Cmd),
+
+ /// Patches
+ #[clap(subcommand)]
+ Patch(patch::Cmd),
+
+ /// Merge points
+ #[clap(subcommand)]
+ MergePoint(mergepoint::Cmd),
+
+ /// Topics
+ #[clap(subcommand)]
+ Topic(topic::Cmd),
+}
+
+impl Cmd {
+ pub fn run(self) -> Result<Output> {
+ match self {
+ Self::Drop(cmd) => cmd.run(),
+ Self::Id(cmd) => cmd.run(),
+ Self::Patch(cmd) => cmd.run(),
+ Self::MergePoint(cmd) => cmd.run(),
+ Self::Topic(cmd) => cmd.run(),
+ }
+ }
+}
diff --git a/src/cmd/drop.rs b/src/cmd/drop.rs
new file mode 100644
index 0000000..208dbd6
--- /dev/null
+++ b/src/cmd/drop.rs
@@ -0,0 +1,205 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ ops::Deref,
+ path::PathBuf,
+};
+
+use anyhow::{
+ ensure,
+ Context,
+};
+use clap::ValueHint;
+use either::Either::Left;
+
+use crate::{
+ cmd,
+ metadata::{
+ self,
+ git::{
+ FromGit,
+ META_FILE_ALTERNATES,
+ META_FILE_MIRRORS,
+ },
+ IdentityId,
+ Signed,
+ },
+ patches::REF_HEADS_PATCHES,
+};
+
+mod bundles;
+pub use bundles::{
+ sync,
+ Bundles,
+ Sync,
+};
+
+mod edit;
+pub use edit::{
+ edit,
+ Edit,
+};
+
+mod init;
+pub use init::{
+ init,
+ Init,
+};
+
+mod serve;
+pub use serve::{
+ serve,
+ Serve,
+};
+
+mod snapshot;
+pub use snapshot::{
+ snapshot,
+ Snapshot,
+};
+
+mod show;
+pub use show::{
+ show,
+ Show,
+};
+
+mod unbundle;
+pub use unbundle::{
+ unbundle,
+ Unbundle,
+};
+
+#[derive(Debug, clap::Subcommand)]
+#[allow(clippy::large_enum_variant)]
+pub enum Cmd {
+ /// Initialise a drop
+ Init(Init),
+ /// Display the drop metadata
+ Show(Show),
+ /// Serve bundles and patch submission over HTTP
+ Serve(Serve),
+ /// Edit the drop metadata
+ Edit(Edit),
+ /// Manage patch bundles
+ #[clap(subcommand)]
+ Bundles(Bundles),
+ /// Take a snapshot of the patches received so far
+ Snapshot(Snapshot),
+ /// Unbundle the entire drop history
+ Unbundle(Unbundle),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Init(args) => init(args).map(cmd::IntoOutput::into_output),
+ Self::Show(args) => show(args).map(cmd::IntoOutput::into_output),
+ Self::Serve(args) => serve(args).map(cmd::IntoOutput::into_output),
+ Self::Edit(args) => edit(args).map(cmd::IntoOutput::into_output),
+ Self::Bundles(cmd) => cmd.run(),
+ Self::Snapshot(args) => snapshot(args).map(cmd::IntoOutput::into_output),
+ Self::Unbundle(args) => unbundle(args).map(cmd::IntoOutput::into_output),
+ }
+ }
+}
+
+#[derive(Debug, clap::Args)]
+struct Common {
+ /// Path to the drop repository
+ #[clap(from_global)]
+ git_dir: PathBuf,
+ /// A list of paths to search for identity repositories
+ #[clap(
+ long,
+ value_parser,
+ value_name = "PATH",
+ env = "IT_ID_PATH",
+ default_value_t,
+ value_hint = ValueHint::DirPath,
+ )]
+ id_path: cmd::util::args::IdSearchPath,
+}
+
+fn find_id(
+ repo: &git2::Repository,
+ id_path: &[git2::Repository],
+ id: &IdentityId,
+) -> cmd::Result<Signed<metadata::Identity>> {
+ let signed = metadata::Identity::from_search_path(id_path, cmd::id::identity_ref(Left(id))?)?
+ .meta
+ .signed;
+
+ let verified_id = signed
+ .verify(cmd::find_parent(repo))
+ .with_context(|| format!("invalid identity {id}"))?;
+ ensure!(
+ &verified_id == id,
+ "ids do not match after verification: expected {id}, found {verified_id}",
+ );
+
+ Ok(signed)
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+struct Editable {
+ description: metadata::drop::Description,
+ roles: metadata::drop::Roles,
+ custom: metadata::Custom,
+}
+
+impl From<metadata::Drop> for Editable {
+ fn from(
+ metadata::Drop {
+ description,
+ roles,
+ custom,
+ ..
+ }: metadata::Drop,
+ ) -> Self {
+ Self {
+ description,
+ roles,
+ custom,
+ }
+ }
+}
+
+impl TryFrom<Editable> for metadata::Drop {
+ type Error = crate::Error;
+
+ fn try_from(
+ Editable {
+ description,
+ roles,
+ custom,
+ }: Editable,
+ ) -> Result<Self, Self::Error> {
+ ensure!(!roles.root.ids.is_empty(), "drop role cannot be empty");
+ ensure!(
+ !roles.snapshot.ids.is_empty(),
+ "snapshot roles cannot be empty"
+ );
+ ensure!(
+ !roles.branches.is_empty(),
+ "at least one branch role is required"
+ );
+ for (name, ann) in &roles.branches {
+ ensure!(
+ !ann.role.ids.is_empty(),
+ "branch role {name} cannot be empty"
+ );
+ ensure!(name.starts_with("refs/heads/"), "not a branch {name}");
+ ensure!(name.deref() != REF_HEADS_PATCHES, "reserved branch {name}");
+ }
+
+ Ok(Self {
+ spec_version: crate::SPEC_VERSION,
+ description,
+ prev: None,
+ roles,
+ custom,
+ })
+ }
+}
diff --git a/src/cmd/drop/bundles.rs b/src/cmd/drop/bundles.rs
new file mode 100644
index 0000000..7c3e726
--- /dev/null
+++ b/src/cmd/drop/bundles.rs
@@ -0,0 +1,32 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::cmd;
+
+mod prune;
+pub use prune::{
+ prune,
+ Prune,
+};
+
+mod sync;
+pub use sync::{
+ sync,
+ Sync,
+};
+
+#[derive(Debug, clap::Subcommand)]
+#[allow(clippy::large_enum_variant)]
+pub enum Bundles {
+ Sync(Sync),
+ Prune(Prune),
+}
+
+impl Bundles {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Sync(args) => sync(args).map(cmd::IntoOutput::into_output),
+ Self::Prune(args) => prune(args).map(cmd::IntoOutput::into_output),
+ }
+ }
+}
diff --git a/src/cmd/drop/bundles/prune.rs b/src/cmd/drop/bundles/prune.rs
new file mode 100644
index 0000000..6bd984d
--- /dev/null
+++ b/src/cmd/drop/bundles/prune.rs
@@ -0,0 +1,113 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::BTreeSet,
+ fs,
+ path::PathBuf,
+ str::FromStr,
+};
+
+use clap::ValueHint;
+
+use crate::{
+ bundle,
+ cfg,
+ cmd::{
+ self,
+ ui::{
+ info,
+ warn,
+ },
+ },
+ git,
+ patches::iter::dropped,
+};
+
+// TODO:
+//
+// - option to prune bundles made obsolete by snapshots
+
+#[derive(Debug, clap::Args)]
+pub struct Prune {
+ /// Path to the drop repository
+ #[clap(from_global)]
+ git_dir: PathBuf,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = cfg::paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// Name of a git ref holding the drop metadata history
+ ///
+ /// All locally tracked drops should be given, otherwise bundles might get
+ /// pruned which are still being referred to.
+ #[clap(long = "drop", value_parser, value_name = "REF")]
+ drop_refs: Vec<String>,
+ /// Pretend to unlink, but don't
+ #[clap(long, value_parser)]
+ dry_run: bool,
+ /// Also remove location files (.uris)
+ #[clap(long, value_parser)]
+ remove_locations: bool,
+}
+
+pub fn prune(args: Prune) -> cmd::Result<Vec<bundle::Hash>> {
+ let repo = git::repo::open_bare(&args.git_dir)?;
+ let bundle_dir = if args.bundle_dir.is_relative() {
+ repo.path().join(args.bundle_dir)
+ } else {
+ args.bundle_dir
+ };
+
+ let mut seen = BTreeSet::new();
+ for short in &args.drop_refs {
+ let drop_ref = repo.resolve_reference_from_short_name(short)?;
+ let ref_name = drop_ref.name().expect("drop references to be valid utf8");
+ info!("Collecting bundle hashes from {ref_name} ...");
+ for record in dropped::records(&repo, ref_name) {
+ let record = record?;
+ seen.insert(*record.bundle_hash());
+ }
+ }
+
+ info!("Traversing bundle dir {} ...", bundle_dir.display());
+ let mut pruned = Vec::new();
+ for entry in fs::read_dir(&bundle_dir)? {
+ let entry = entry?;
+ let path = entry.path();
+ match path.extension() {
+ Some(ext) if ext == bundle::FILE_EXTENSION => {
+ let name = path.file_stem();
+ match name
+ .and_then(|n| n.to_str())
+ .and_then(|s| bundle::Hash::from_str(s).ok())
+ {
+ Some(hash) => {
+ if !seen.contains(&hash) {
+ if !args.dry_run {
+ fs::remove_file(&path)?;
+ }
+ pruned.push(hash);
+ }
+ },
+ None => warn!("Ignoring {}: file name not a bundle hash", path.display()),
+ }
+ },
+ Some(ext) if ext == bundle::list::FILE_EXTENSION => {
+ if args.remove_locations {
+ fs::remove_file(&path)?;
+ }
+ },
+ _ => warn!("Ignoring {}: missing .bundle", path.display()),
+ }
+ }
+
+ Ok(pruned)
+}
diff --git a/src/cmd/drop/bundles/sync.rs b/src/cmd/drop/bundles/sync.rs
new file mode 100644
index 0000000..21fd58b
--- /dev/null
+++ b/src/cmd/drop/bundles/sync.rs
@@ -0,0 +1,276 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ borrow::Cow,
+ mem,
+ num::NonZeroUsize,
+ path::PathBuf,
+ sync::{
+ Arc,
+ Mutex,
+ },
+ time::{
+ SystemTime,
+ UNIX_EPOCH,
+ },
+};
+
+use anyhow::anyhow;
+use clap::ValueHint;
+use either::Either::{
+ Left,
+ Right,
+};
+use threadpool::ThreadPool;
+use url::Url;
+
+use crate::{
+ bundle,
+ cfg,
+ cmd::{
+ self,
+ drop::Common,
+ ui::{
+ debug,
+ info,
+ warn,
+ },
+ },
+ git::{
+ self,
+ if_not_found_none,
+ },
+ patches::{
+ self,
+ iter::dropped,
+ record,
+ REF_IT_PATCHES,
+ },
+};
+
+/// Max number of locations to store from the remote for which we don't know if
+/// they'd succeed or not.
+pub const MAX_UNTRIED_LOCATIONS: usize = 3;
+
+#[derive(Debug, clap::Args)]
+pub struct Sync {
+ #[clap(flatten)]
+ common: Common,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = cfg::paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// Name of the git ref holding the drop metadata history
+ #[clap(long = "drop", value_parser, value_name = "REF")]
+ drop_ref: Option<String>,
+ /// Base URL to fetch from
+ #[clap(long, value_parser, value_name = "URL", value_hint = ValueHint::Url)]
+ url: Url,
+ /// Fetch via IPFS
+ #[clap(
+ long,
+ value_parser,
+ value_name = "URL",
+ value_hint = ValueHint::Url,
+ env = "IPFS_GATEWAY",
+ default_value_t = Url::parse("https://ipfs.io").unwrap(),
+ )]
+ ipfs_gateway: Url,
+ /// Fetch even if the bundle already exists locally
+ #[clap(long, value_parser)]
+ overwrite: bool,
+ /// Ignore snapshots if encountered
+ #[clap(long, value_parser)]
+ no_snapshots: bool,
+ /// Maximum number of concurrent downloads. Default is the number of
+ /// available cores.
+ #[clap(short, long, value_parser, default_value_t = def_jobs())]
+ jobs: NonZeroUsize,
+}
+
+fn def_jobs() -> NonZeroUsize {
+ NonZeroUsize::new(num_cpus::get()).unwrap_or_else(|| NonZeroUsize::new(1).unwrap())
+}
+
+pub fn sync(args: Sync) -> cmd::Result<Vec<bundle::Info>> {
+ let repo = git::repo::open_bare(&args.common.git_dir)?;
+ let bundle_dir = if args.bundle_dir.is_relative() {
+ repo.path().join(args.bundle_dir)
+ } else {
+ args.bundle_dir
+ };
+ let drop_ref = match args.drop_ref {
+ Some(rev) => if_not_found_none(repo.resolve_reference_from_short_name(&rev))?
+ .ok_or_else(|| anyhow!("no ref matching {rev} found"))?
+ .name()
+ .ok_or_else(|| anyhow!("invalid drop"))?
+ .to_owned(),
+ None => REF_IT_PATCHES.to_owned(),
+ };
+ let base_url = args.url.join("bundles/")?;
+ let fetcher = Arc::new(Fetcher {
+ fetcher: bundle::Fetcher::default(),
+ bundle_dir,
+ base_url: base_url.clone(),
+ ipfs_gateway: args.ipfs_gateway,
+ });
+
+ let pool = ThreadPool::new(args.jobs.get());
+
+ let fetched = Arc::new(Mutex::new(Vec::new()));
+ let mut chasing_snaphots = false;
+ for record in dropped::records(&repo, &drop_ref) {
+ let record = record?;
+ let hexdig = record.bundle_hash().to_string();
+
+ if record.is_snapshot() {
+ if args.no_snapshots {
+ info!("Skipping snapshot bundle {hexdig}");
+ continue;
+ } else {
+ chasing_snaphots = true;
+ }
+ } else if chasing_snaphots && !record.is_mergepoint() {
+ info!("Skipping non-snapshot bundle {hexdig}");
+ continue;
+ }
+
+ if !args.overwrite && record.bundle_path(&fetcher.bundle_dir).exists() {
+ info!("Skipping existing bundle {hexdig}");
+ continue;
+ }
+
+ let record::BundleInfo {
+ info: bundle::Info { len, hash, .. },
+ prerequisites,
+ ..
+ } = record.bundle_info();
+ let url = base_url.join(&hexdig)?;
+
+ pool.execute({
+ let len = *len;
+ let hash = *hash;
+ let fetched = Arc::clone(&fetched);
+ let fetcher = Arc::clone(&fetcher);
+ move || match fetcher.try_fetch(url, len, &hash) {
+ Ok(hash) => fetched.lock().unwrap().push(hash),
+ Err(e) => warn!("Download failed: {e}"),
+ }
+ });
+
+ if record.is_snapshot() && prerequisites.is_empty() {
+ info!("Full snapshot encountered, stopping here");
+ break;
+ }
+ }
+
+ pool.join();
+ let fetched = {
+ let mut guard = fetched.lock().unwrap();
+ mem::take(&mut *guard)
+ };
+
+ Ok(fetched)
+}
+
+struct Fetcher {
+ fetcher: bundle::Fetcher,
+ bundle_dir: PathBuf,
+ base_url: Url,
+ ipfs_gateway: Url,
+}
+
+impl Fetcher {
+ fn try_fetch(&self, url: Url, len: u64, hash: &bundle::Hash) -> cmd::Result<bundle::Info> {
+ info!("Fetching {url} ...");
+
+ let expect = bundle::Expect {
+ len,
+ hash,
+ checksum: None,
+ };
+ let mut locations = Vec::new();
+ let (fetched, origin) = self
+ .fetcher
+ .fetch(&url, &self.bundle_dir, expect)
+ .and_then(|resp| match resp {
+ Right(fetched) => Ok((fetched, url)),
+ Left(lst) => {
+ info!("{url}: response was a bundle list, trying alternate locations");
+
+ let mut iter = lst.bundles.into_iter();
+ let mut found = None;
+
+ for bundle::Location { uri, .. } in &mut iter {
+ if let Some(url) = self.url_from_uri(uri) {
+ if let Ok(Right(info)) =
+ self.fetcher.fetch(&url, &self.bundle_dir, expect)
+ {
+ found = Some((info, url));
+ break;
+ }
+ }
+ }
+
+ // If there are bundle uris left, remember a few
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("backwards system clock")
+ .as_secs();
+ locations.extend(
+ iter
+ // Don't let the remote inflate the priority of
+ // unverified locations
+ .filter(|loc| loc.creation_token.map(|t| t < now).unwrap_or(true))
+ // Only known protocols, relative to base url
+ .filter_map(|loc| {
+ let url = loc.uri.abs(&self.base_url).ok()?;
+ matches!(url.scheme(), "http" | "https" | "ipfs").then(|| {
+ bundle::Location {
+ uri: url.into_owned().into(),
+ ..loc
+ }
+ })
+ })
+ .take(MAX_UNTRIED_LOCATIONS),
+ );
+
+ found.ok_or_else(|| anyhow!("{url}: no reachable location found"))
+ },
+ })?;
+
+ info!("Downloaded {hash} from {origin}");
+ let bundle = patches::Bundle::from_fetched(fetched)?;
+ bundle.write_bundle_list(locations)?;
+
+ Ok(bundle.into())
+ }
+
+ fn url_from_uri(&self, uri: bundle::Uri) -> Option<Url> {
+ uri.abs(&self.base_url)
+ .map_err(Into::into)
+ .and_then(|url: Cow<Url>| -> cmd::Result<Url> {
+ match url.scheme() {
+ "http" | "https" => Ok(url.into_owned()),
+ "ipfs" => {
+ let cid = url
+ .host_str()
+ .ok_or_else(|| anyhow!("{url}: host part not an IPFS CID"))?;
+ let url = self.ipfs_gateway.join(&format!("/ipfs/{cid}"))?;
+ Ok(url)
+ },
+ _ => Err(anyhow!("{url}: unsupported protocol")),
+ }
+ })
+ .map_err(|e| debug!("discarding {}: {}", uri.as_str(), e))
+ .ok()
+ }
+}
diff --git a/src/cmd/drop/edit.rs b/src/cmd/drop/edit.rs
new file mode 100644
index 0000000..9103819
--- /dev/null
+++ b/src/cmd/drop/edit.rs
@@ -0,0 +1,368 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ iter,
+ path::PathBuf,
+};
+
+use anyhow::{
+ anyhow,
+ ensure,
+};
+
+use super::{
+ find_id,
+ Common,
+ Editable,
+};
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ ui::{
+ self,
+ edit_commit_message,
+ edit_metadata,
+ info,
+ },
+ Aborted,
+ },
+ git::{
+ self,
+ refs,
+ Refname,
+ },
+ json,
+ keys::Signer,
+ metadata::{
+ self,
+ git::{
+ FromGit,
+ GitDrop,
+ META_FILE_ALTERNATES,
+ META_FILE_DROP,
+ META_FILE_MIRRORS,
+ },
+ IdentityId,
+ Metadata,
+ },
+ patches::{
+ self,
+ REF_HEADS_PATCHES,
+ REF_IT_PATCHES,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Edit {
+ #[clap(flatten)]
+ common: Common,
+ /// Commit message for this edit
+ ///
+ /// Like git, $EDITOR will be invoked if not specified.
+ #[clap(short, long, value_parser)]
+ message: Option<String>,
+
+ #[clap(subcommand)]
+ cmd: Option<Cmd>,
+}
+
+#[derive(Debug, clap::Subcommand)]
+enum Cmd {
+ /// Edit the mirrors file
+ Mirrors,
+ /// Edit the alternates file
+ Alternates,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ repo: PathBuf,
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn edit(args: Edit) -> cmd::Result<Output> {
+ let Common { git_dir, id_path } = args.common;
+
+ let repo = git::repo::open(git_dir)?;
+ let drop_ref = if repo.is_bare() {
+ REF_HEADS_PATCHES
+ } else {
+ REF_IT_PATCHES
+ }
+ .parse()
+ .unwrap();
+
+ let id_path = id_path.open_git();
+ git::add_alternates(&repo, &id_path)?;
+ let cfg = repo.config()?.snapshot()?;
+ let signer = cfg::signer(&cfg, ui::askpass)?;
+ let signer_id = SignerIdentity::new(&signer, &repo, &cfg, &id_path)?;
+ let meta = metadata::Drop::from_tip(&repo, &drop_ref)?;
+
+ let s = EditState {
+ repo,
+ id_path,
+ signer,
+ signer_id,
+ drop_ref,
+ meta,
+ };
+
+ match args.cmd {
+ None => s.edit_drop(args.message),
+ Some(Cmd::Mirrors) => s.edit_mirrors(args.message),
+ Some(Cmd::Alternates) => s.edit_alternates(args.message),
+ }
+}
+
+struct EditState<S> {
+ repo: git2::Repository,
+ id_path: Vec<git2::Repository>,
+ signer: S,
+ signer_id: SignerIdentity,
+ drop_ref: Refname,
+ meta: GitDrop,
+}
+
+impl<S: Signer + 'static> EditState<S> {
+ fn edit_drop(mut self, message: Option<String>) -> cmd::Result<Output> {
+ let GitDrop {
+ hash: parent_hash,
+ signed: metadata::Signed { signed: parent, .. },
+ } = self.meta;
+
+ ensure!(
+ self.signer_id.can_edit_drop(&parent),
+ "signer identity not allowed to edit the drop metadata"
+ );
+
+ let mut meta: metadata::Drop = edit_metadata(Editable::from(parent.clone()))?.try_into()?;
+ if meta.canonicalise()? == parent.canonicalise()? {
+ info!("Document unchanged");
+ cmd::abort!();
+ }
+ meta.prev = Some(parent_hash);
+
+ let signed = Metadata::drop(&meta).sign(iter::once(&mut self.signer as &mut dyn Signer))?;
+
+ let mut tx = refs::Transaction::new(&self.repo)?;
+ let drop_ref = tx.lock_ref(self.drop_ref)?;
+
+ let parent = self
+ .repo
+ .find_reference(drop_ref.name())?
+ .peel_to_commit()?;
+ let parent_tree = parent.tree()?;
+ let mut root = self.repo.treebuilder(Some(&parent_tree))?;
+ patches::Record::remove_from(&mut root)?;
+
+ let mut ids = self
+ .repo
+ .treebuilder(get_tree(&self.repo, &root, "ids")?.as_ref())?;
+ let identities = meta
+ .roles
+ .ids()
+ .into_iter()
+ .map(|id| find_id(&self.repo, &self.id_path, &id).map(|signed| (id, signed)))
+ .collect::<Result<Vec<_>, _>>()?;
+ for (iid, id) in identities {
+ let iid = iid.to_string();
+ let mut tb = self
+ .repo
+ .treebuilder(get_tree(&self.repo, &ids, &iid)?.as_ref())?;
+ metadata::identity::fold_to_tree(&self.repo, &mut tb, id)?;
+ ids.insert(&iid, tb.write()?, git2::FileMode::Tree.into())?;
+ }
+ root.insert("ids", ids.write()?, git2::FileMode::Tree.into())?;
+
+ root.insert(
+ META_FILE_DROP,
+ json::to_blob(&self.repo, &signed)?,
+ git2::FileMode::Blob.into(),
+ )?;
+ let tree = self.repo.find_tree(root.write()?)?;
+
+ let msg = message.map(Ok).unwrap_or_else(|| {
+ edit_commit_message(&self.repo, drop_ref.name(), &parent_tree, &tree)
+ })?;
+ let commit = git::commit_signed(&mut self.signer, &self.repo, msg, &tree, &[&parent])?;
+ drop_ref.set_target(commit, "it: metadata edit");
+
+ tx.commit()?;
+
+ Ok(Output {
+ repo: self.repo.path().to_owned(),
+ refname: drop_ref.into(),
+ commit,
+ })
+ }
+
+ pub fn edit_mirrors(mut self, message: Option<String>) -> cmd::Result<Output> {
+ ensure!(
+ self.signer_id.can_edit_mirrors(&self.meta.signed.signed),
+ "signer identity not allowed to edit mirrors"
+ );
+
+ let prev = metadata::Mirrors::from_tip(&self.repo, &self.drop_ref)
+ .map(|m| m.signed.signed)
+ .or_else(|e| {
+ if e.is::<metadata::git::error::FileNotFound>() {
+ Ok(Default::default())
+ } else {
+ Err(e)
+ }
+ })?;
+ let prev_canonical = prev.canonicalise()?;
+ let meta = edit_metadata(prev)?;
+ if meta.canonicalise()? == prev_canonical {
+ info!("Document unchanged");
+ cmd::abort!();
+ }
+
+ let signed =
+ Metadata::mirrors(meta).sign(iter::once(&mut self.signer as &mut dyn Signer))?;
+
+ let mut tx = refs::Transaction::new(&self.repo)?;
+ let drop_ref = tx.lock_ref(self.drop_ref)?;
+
+ let parent = self
+ .repo
+ .find_reference(drop_ref.name())?
+ .peel_to_commit()?;
+ let parent_tree = parent.tree()?;
+ let mut root = self.repo.treebuilder(Some(&parent_tree))?;
+ patches::Record::remove_from(&mut root)?;
+ root.insert(
+ META_FILE_MIRRORS,
+ json::to_blob(&self.repo, &signed)?,
+ git2::FileMode::Blob.into(),
+ )?;
+ let tree = self.repo.find_tree(root.write()?)?;
+
+ let msg = message.map(Ok).unwrap_or_else(|| {
+ edit_commit_message(&self.repo, drop_ref.name(), &parent_tree, &tree)
+ })?;
+ let commit = git::commit_signed(&mut self.signer, &self.repo, msg, &tree, &[&parent])?;
+ drop_ref.set_target(commit, "it: mirrors edit");
+
+ tx.commit()?;
+
+ Ok(Output {
+ repo: self.repo.path().to_owned(),
+ refname: drop_ref.into(),
+ commit,
+ })
+ }
+
+ pub fn edit_alternates(mut self, message: Option<String>) -> cmd::Result<Output> {
+ ensure!(
+ self.signer_id.can_edit_mirrors(&self.meta.signed.signed),
+ "signer identity not allowed to edit alternates"
+ );
+
+ let prev = metadata::Alternates::from_tip(&self.repo, &self.drop_ref)
+ .map(|m| m.signed.signed)
+ .or_else(|e| {
+ if e.is::<metadata::git::error::FileNotFound>() {
+ Ok(Default::default())
+ } else {
+ Err(e)
+ }
+ })?;
+ let prev_canonical = prev.canonicalise()?;
+ let meta = edit_metadata(prev)?;
+ if meta.canonicalise()? == prev_canonical {
+ info!("Document unchanged");
+ cmd::abort!();
+ }
+
+ let signed =
+ Metadata::alternates(meta).sign(iter::once(&mut self.signer as &mut dyn Signer))?;
+
+ let mut tx = refs::Transaction::new(&self.repo)?;
+ let drop_ref = tx.lock_ref(self.drop_ref)?;
+
+ let parent = self
+ .repo
+ .find_reference(drop_ref.name())?
+ .peel_to_commit()?;
+ let parent_tree = parent.tree()?;
+ let mut root = self.repo.treebuilder(Some(&parent_tree))?;
+ patches::Record::remove_from(&mut root)?;
+ root.insert(
+ META_FILE_ALTERNATES,
+ json::to_blob(&self.repo, &signed)?,
+ git2::FileMode::Blob.into(),
+ )?;
+ let tree = self.repo.find_tree(root.write()?)?;
+
+ let msg = message.map(Ok).unwrap_or_else(|| {
+ edit_commit_message(&self.repo, drop_ref.name(), &parent_tree, &tree)
+ })?;
+ let commit = git::commit_signed(&mut self.signer, &self.repo, msg, &tree, &[&parent])?;
+ drop_ref.set_target(commit, "it: alternates edit");
+
+ tx.commit()?;
+
+ Ok(Output {
+ repo: self.repo.path().to_owned(),
+ refname: drop_ref.into(),
+ commit,
+ })
+ }
+}
+
+fn get_tree<'a>(
+ repo: &'a git2::Repository,
+ builder: &git2::TreeBuilder,
+ name: &str,
+) -> cmd::Result<Option<git2::Tree<'a>>> {
+ if let Some(entry) = builder.get(name)? {
+ return Ok(Some(
+ entry
+ .to_object(repo)?
+ .into_tree()
+ .map_err(|_| anyhow!("{name} is not a tree"))?,
+ ));
+ }
+
+ Ok(None)
+}
+
+struct SignerIdentity {
+ id: IdentityId,
+}
+
+impl SignerIdentity {
+ pub fn new<S: Signer>(
+ signer: &S,
+ repo: &git2::Repository,
+ cfg: &git2::Config,
+ id_path: &[git2::Repository],
+ ) -> cmd::Result<Self> {
+ let id =
+ cfg::git::identity(cfg)?.ok_or_else(|| anyhow!("signer identity not in gitconfig"))?;
+ let meta = find_id(repo, id_path, &id)?;
+ let keyid = metadata::KeyId::from(signer.ident());
+
+ ensure!(
+ meta.signed.keys.contains_key(&keyid),
+ "signing key {keyid} is not in identity {id}"
+ );
+
+ Ok(Self { id })
+ }
+
+ pub fn can_edit_drop(&self, parent: &metadata::Drop) -> bool {
+ parent.roles.root.ids.contains(&self.id)
+ }
+
+ pub fn can_edit_mirrors(&self, parent: &metadata::Drop) -> bool {
+ parent.roles.mirrors.ids.contains(&self.id)
+ }
+}
diff --git a/src/cmd/drop/init.rs b/src/cmd/drop/init.rs
new file mode 100644
index 0000000..b843255
--- /dev/null
+++ b/src/cmd/drop/init.rs
@@ -0,0 +1,194 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ iter,
+ num::NonZeroUsize,
+ path::PathBuf,
+};
+
+use anyhow::{
+ anyhow,
+ ensure,
+};
+
+use super::{
+ find_id,
+ Common,
+ Editable,
+};
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ ui::{
+ self,
+ edit_metadata,
+ },
+ },
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ },
+ json,
+ metadata::{
+ self,
+ git::META_FILE_DROP,
+ Metadata,
+ },
+ patches::{
+ REF_HEADS_PATCHES,
+ REF_IT_PATCHES,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Init {
+ #[clap(flatten)]
+ common: Common,
+ /// A description for this drop instance, max. 128 characters
+ #[clap(long, value_parser, value_name = "STRING")]
+ description: metadata::drop::Description,
+ /// If the repository does not already exist, initialise it as non-bare
+ ///
+ /// A drop is usually initialised inside an already existing git repository,
+ /// or as a standalone drop repository. The latter is advisable for serving
+ /// over the network.
+ ///
+ /// When init is given a directory which does not already exist, it is
+ /// assumed that a standalone drop should be created, and thus the
+ /// repository is initialised as bare. This behaviour can be overridden
+ /// by --no-bare.
+ #[clap(long, value_parser)]
+ no_bare: bool,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ repo: PathBuf,
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn init(args: Init) -> cmd::Result<Output> {
+ let Common { git_dir, id_path } = args.common;
+ let drop_ref: Refname = REF_IT_PATCHES.parse().unwrap();
+
+ let repo = git::repo::open_or_init(
+ git_dir,
+ git::repo::InitOpts {
+ bare: !args.no_bare,
+ description: "`it` drop",
+ initial_head: &drop_ref,
+ },
+ )?;
+
+ let mut tx = refs::Transaction::new(&repo)?;
+ let drop_ref = tx.lock_ref(drop_ref)?;
+ ensure!(
+ if_not_found_none(repo.refname_to_id(drop_ref.name()))?.is_none(),
+ "{} already exists",
+ drop_ref
+ );
+
+ let id_path = id_path.open_git();
+ git::add_alternates(&repo, &id_path)?;
+
+ let cfg = repo.config()?.snapshot()?;
+ let mut signer = cfg::signer(&cfg, ui::askpass)?;
+ let signer_id = {
+ let iid =
+ cfg::git::identity(&cfg)?.ok_or_else(|| anyhow!("signer identity not in gitconfig"))?;
+ let id = find_id(&repo, &id_path, &iid)?;
+ let keyid = metadata::KeyId::from(signer.ident());
+ ensure!(
+ id.signed.keys.contains_key(&keyid),
+ "signing key {keyid} is not in identity {iid}"
+ );
+
+ iid
+ };
+
+ let default = {
+ let default_role = metadata::drop::Role {
+ ids: [signer_id].into(),
+ threshold: NonZeroUsize::new(1).unwrap(),
+ };
+ let default_branch = cfg::git::default_branch(&cfg)?;
+
+ metadata::Drop {
+ spec_version: crate::SPEC_VERSION,
+ description: args.description,
+ prev: None,
+ custom: Default::default(),
+ roles: metadata::drop::Roles {
+ root: default_role.clone(),
+ snapshot: default_role.clone(),
+ mirrors: default_role.clone(),
+ branches: [(
+ default_branch,
+ metadata::drop::Annotated {
+ role: default_role,
+ description: metadata::drop::Description::try_from(
+ "the default branch".to_owned(),
+ )
+ .unwrap(),
+ },
+ )]
+ .into(),
+ },
+ }
+ };
+ let meta: metadata::Drop = edit_metadata(Editable::from(default))?.try_into()?;
+ ensure!(
+ meta.roles.root.ids.contains(&signer_id),
+ "signing identity {signer_id} is lacking the drop role required to sign the metadata"
+ );
+ let signed = Metadata::drop(&meta).sign(iter::once(&mut signer))?;
+
+ let mut root = repo.treebuilder(None)?;
+ let mut ids = repo.treebuilder(None)?;
+ let identities = meta
+ .roles
+ .ids()
+ .into_iter()
+ .map(|id| find_id(&repo, &id_path, &id).map(|signed| (id, signed)))
+ .collect::<Result<Vec<_>, _>>()?;
+ for (iid, id) in identities {
+ let iid = iid.to_string();
+ let mut tb = repo.treebuilder(None)?;
+ metadata::identity::fold_to_tree(&repo, &mut tb, id)?;
+ ids.insert(&iid, tb.write()?, git2::FileMode::Tree.into())?;
+ }
+ root.insert("ids", ids.write()?, git2::FileMode::Tree.into())?;
+ root.insert(
+ META_FILE_DROP,
+ json::to_blob(&repo, &signed)?,
+ git2::FileMode::Blob.into(),
+ )?;
+ let tree = repo.find_tree(root.write()?)?;
+ let msg = format!("Create drop '{}'", meta.description);
+ let commit = git::commit_signed(&mut signer, &repo, msg, &tree, &[])?;
+
+ if repo.is_bare() {
+ // Arrange refs to be `git-clone`-friendly
+ let heads_patches = tx.lock_ref(REF_HEADS_PATCHES.parse()?)?;
+ heads_patches.set_target(commit, "it: create");
+ drop_ref.set_symbolic_target(heads_patches.name().clone(), String::new());
+ repo.set_head(heads_patches.name())?;
+ } else {
+ drop_ref.set_target(commit, "it: create");
+ }
+
+ tx.commit()?;
+
+ Ok(Output {
+ repo: repo.path().to_owned(),
+ refname: drop_ref.into(),
+ commit,
+ })
+}
diff --git a/src/cmd/drop/serve.rs b/src/cmd/drop/serve.rs
new file mode 100644
index 0000000..7540d58
--- /dev/null
+++ b/src/cmd/drop/serve.rs
@@ -0,0 +1,140 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ fs::File,
+ io::Read,
+ path::PathBuf,
+ str::FromStr,
+};
+
+use clap::ValueHint;
+use url::Url;
+
+use super::Common;
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ },
+ http,
+ patches::{
+ REF_IT_BUNDLES,
+ REF_IT_PATCHES,
+ REF_IT_SEEN,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Serve {
+ #[clap(flatten)]
+ common: Common,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = cfg::paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// Ref prefix under which to store the refs contained in patch bundles
+ #[clap(
+ long,
+ value_parser,
+ value_name = "REF",
+ default_value_t = Refname::from_str(REF_IT_BUNDLES).unwrap()
+ )]
+ unbundle_prefix: Refname,
+ /// The refname anchoring the seen objects tree
+ #[clap(
+ long,
+ value_parser,
+ value_name = "REF",
+ default_value_t = Refname::from_str(REF_IT_SEEN).unwrap()
+ )]
+ seen_ref: Refname,
+ /// 'host:port' to listen on
+ #[clap(
+ long,
+ value_parser,
+ value_name = "HOST:PORT",
+ default_value = "127.0.0.1:8084"
+ )]
+ listen: String,
+ /// Number of threads to use for the server
+ ///
+ /// If not set, the number of available cores is used.
+ #[clap(long, value_parser, value_name = "INT")]
+ threads: Option<usize>,
+ /// PEM-encoded TLS certificate
+ ///
+ /// Requires 'tls-key'. If not set (the default), the server will not use
+ /// TLS.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "FILE",
+ requires = "tls_key",
+ value_hint = ValueHint::FilePath
+ )]
+ tls_cert: Option<PathBuf>,
+ /// PEM-encoded TLS private key
+ ///
+ /// Requires 'tls-cert'. If not set (the default), the server will not use
+ /// TLS.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "FILE",
+ requires = "tls_cert",
+ value_hint = ValueHint::FilePath
+ )]
+ tls_key: Option<PathBuf>,
+ /// IPFS API to publish received patch bundle to
+ #[clap(
+ long,
+ value_parser,
+ value_name = "URL",
+ value_hint = ValueHint::Url,
+ )]
+ ipfs_api: Option<Url>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output;
+
+pub fn serve(args: Serve) -> cmd::Result<Output> {
+ let tls = args
+ .tls_cert
+ .map(|cert_path| -> cmd::Result<http::SslConfig> {
+ let mut certificate = Vec::new();
+ let mut private_key = Vec::new();
+ File::open(cert_path)?.read_to_end(&mut certificate)?;
+ File::open(args.tls_key.expect("presence of 'tls-key' ensured by clap"))?
+ .read_to_end(&mut private_key)?;
+
+ Ok(http::SslConfig {
+ certificate,
+ private_key,
+ })
+ })
+ .transpose()?;
+
+ http::serve(
+ args.listen,
+ http::Options {
+ git_dir: args.common.git_dir,
+ bundle_dir: args.bundle_dir,
+ unbundle_prefix: args.unbundle_prefix.into(),
+ drop_ref: REF_IT_PATCHES.into(),
+ seen_ref: args.seen_ref.into(),
+ threads: args.threads,
+ tls,
+ ipfs_api: args.ipfs_api,
+ },
+ )
+}
diff --git a/src/cmd/drop/show.rs b/src/cmd/drop/show.rs
new file mode 100644
index 0000000..e3fdcfc
--- /dev/null
+++ b/src/cmd/drop/show.rs
@@ -0,0 +1,208 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::BTreeMap,
+ io,
+ path::PathBuf,
+};
+
+use anyhow::Context;
+
+use super::{
+ Common,
+ META_FILE_ALTERNATES,
+ META_FILE_MIRRORS,
+};
+use crate::{
+ cmd::{
+ self,
+ util::args::Refname,
+ FromGit as _,
+ GitAlternates,
+ GitDrop,
+ GitMirrors,
+ },
+ git,
+ metadata::{
+ self,
+ ContentHash,
+ IdentityId,
+ KeySet,
+ },
+ patches::REF_IT_PATCHES,
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Show {
+ #[clap(flatten)]
+ common: Common,
+ /// Name of the git ref holding the drop metadata history
+ #[clap(
+ long = "drop",
+ value_parser,
+ value_name = "REF",
+ default_value_t = REF_IT_PATCHES.parse().unwrap(),
+ )]
+ drop_ref: Refname,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ repo: PathBuf,
+ refname: Refname,
+ drop: Data<metadata::Drop>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ mirrors: Option<Data<metadata::Mirrors>>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ alternates: Option<Data<metadata::Alternates>>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Data<T> {
+ hash: ContentHash,
+ status: Status,
+ json: T,
+}
+
+#[derive(serde::Serialize)]
+#[serde(rename_all = "UPPERCASE")]
+pub enum Status {
+ Verified,
+ #[serde(with = "crate::serde::display")]
+ Invalid(metadata::error::Verification),
+}
+
+impl From<Result<(), metadata::error::Verification>> for Status {
+ fn from(r: Result<(), metadata::error::Verification>) -> Self {
+ r.map(|()| Self::Verified).unwrap_or_else(Self::Invalid)
+ }
+}
+
+pub fn show(args: Show) -> cmd::Result<Output> {
+ let Common { git_dir, .. } = args.common;
+ let drop_ref = args.drop_ref;
+
+ let repo = git::repo::open(git_dir)?;
+
+ let GitDrop {
+ hash,
+ signed: metadata::Signed {
+ signed: drop,
+ signatures,
+ },
+ } = metadata::Drop::from_tip(&repo, &drop_ref)?;
+
+ let mut signer_cache = SignerCache::new(&repo, &drop_ref)?;
+ let status = drop
+ .verify(
+ &signatures,
+ cmd::find_parent(&repo),
+ find_signer(&mut signer_cache),
+ )
+ .into();
+
+ let mut mirrors = None;
+ let mut alternates = None;
+
+ let tree = repo.find_reference(&drop_ref)?.peel_to_commit()?.tree()?;
+ if let Some(entry) = tree.get_name(META_FILE_MIRRORS) {
+ let blob = entry.to_object(&repo)?.peel_to_blob()?;
+ let GitMirrors { hash, signed } = metadata::Mirrors::from_blob(&blob)?;
+ let status = drop
+ .verify_mirrors(&signed, find_signer(&mut signer_cache))
+ .into();
+
+ mirrors = Some(Data {
+ hash,
+ status,
+ json: signed.signed,
+ });
+ }
+
+ if let Some(entry) = tree.get_name(META_FILE_ALTERNATES) {
+ let blob = entry.to_object(&repo)?.peel_to_blob()?;
+ let GitAlternates { hash, signed } = metadata::Alternates::from_blob(&blob)?;
+ let status = drop
+ .verify_alternates(&signed, find_signer(&mut signer_cache))
+ .into();
+
+ alternates = Some(Data {
+ hash,
+ status,
+ json: signed.signed,
+ });
+ }
+
+ Ok(Output {
+ repo: repo.path().to_owned(),
+ refname: drop_ref,
+ drop: Data {
+ hash,
+ status,
+ json: drop,
+ },
+ mirrors,
+ alternates,
+ })
+}
+
+struct SignerCache<'a> {
+ repo: &'a git2::Repository,
+ root: git2::Tree<'a>,
+ keys: BTreeMap<IdentityId, KeySet<'static>>,
+}
+
+impl<'a> SignerCache<'a> {
+ pub(self) fn new(repo: &'a git2::Repository, refname: &Refname) -> git::Result<Self> {
+ let root = {
+ let id = repo
+ .find_reference(refname)?
+ .peel_to_tree()?
+ .get_name("ids")
+ .ok_or_else(|| {
+ git2::Error::new(
+ git2::ErrorCode::NotFound,
+ git2::ErrorClass::Tree,
+ "'ids' tree not found",
+ )
+ })?
+ .id();
+ repo.find_tree(id)?
+ };
+ let keys = BTreeMap::new();
+
+ Ok(Self { repo, root, keys })
+ }
+}
+
+fn find_signer<'a>(
+ cache: &'a mut SignerCache,
+) -> impl FnMut(&IdentityId) -> io::Result<KeySet<'static>> + 'a {
+ fn go(
+ repo: &git2::Repository,
+ root: &git2::Tree,
+ keys: &mut BTreeMap<IdentityId, KeySet<'static>>,
+ id: &IdentityId,
+ ) -> cmd::Result<KeySet<'static>> {
+ match keys.get(id) {
+ Some(keys) => Ok(keys.clone()),
+ None => {
+ let (id, verified) = metadata::identity::find_in_tree(repo, root, id)
+ .with_context(|| format!("identity {id} failed to verify"))?
+ .into_parts();
+ keys.insert(id, verified.keys.clone());
+ Ok(verified.keys)
+ },
+ }
+ }
+
+ |id| go(cache.repo, &cache.root, &mut cache.keys, id).map_err(as_io)
+}
+
+fn as_io<E>(e: E) -> io::Error
+where
+ E: Into<Box<dyn std::error::Error + Send + Sync>>,
+{
+ io::Error::new(io::ErrorKind::Other, e)
+}
diff --git a/src/cmd/drop/snapshot.rs b/src/cmd/drop/snapshot.rs
new file mode 100644
index 0000000..b1348d3
--- /dev/null
+++ b/src/cmd/drop/snapshot.rs
@@ -0,0 +1,20 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::{
+ cmd::{
+ self,
+ patch,
+ },
+ patches,
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Snapshot {
+ #[clap(flatten)]
+ common: patch::Common,
+}
+
+pub fn snapshot(Snapshot { common }: Snapshot) -> cmd::Result<patches::Record> {
+ patch::create(patch::Kind::Snapshot { common })
+}
diff --git a/src/cmd/drop/unbundle.rs b/src/cmd/drop/unbundle.rs
new file mode 100644
index 0000000..a9c9f77
--- /dev/null
+++ b/src/cmd/drop/unbundle.rs
@@ -0,0 +1,93 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::BTreeMap,
+ path::PathBuf,
+};
+
+use anyhow::anyhow;
+use clap::ValueHint;
+
+use crate::{
+ cmd,
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ Refname,
+ },
+ patches::{
+ self,
+ iter::dropped,
+ Bundle,
+ REF_IT_BUNDLES,
+ REF_IT_PATCHES,
+ },
+ paths,
+};
+
+// TODO:
+//
+// - require drop metadata verification
+// - abort if existing ref would be set to a different target (or --force)
+// - honour snapshots
+//
+
+#[derive(Debug, clap::Args)]
+pub struct Unbundle {
+ #[clap(from_global)]
+ git_dir: PathBuf,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// The drop history to find the topic in
+ #[clap(value_parser)]
+ drop: Option<String>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ updated: BTreeMap<Refname, git::serde::oid::Oid>,
+}
+
+pub fn unbundle(args: Unbundle) -> cmd::Result<Output> {
+ let repo = git::repo::open(&args.git_dir)?;
+ let bundle_dir = if args.bundle_dir.is_relative() {
+ repo.path().join(args.bundle_dir)
+ } else {
+ args.bundle_dir
+ };
+ let drop = match args.drop {
+ Some(rev) => if_not_found_none(repo.resolve_reference_from_short_name(&rev))?
+ .ok_or_else(|| anyhow!("no ref matching {rev} found"))?
+ .name()
+ .ok_or_else(|| anyhow!("invalid drop"))?
+ .to_owned(),
+ None => REF_IT_PATCHES.to_owned(),
+ };
+
+ let odb = repo.odb()?;
+ let mut tx = refs::Transaction::new(&repo)?;
+ let mut up = BTreeMap::new();
+ for rec in dropped::records_rev(&repo, &drop) {
+ let rec = rec?;
+ let bundle = Bundle::from_stored(&bundle_dir, rec.bundle_info().as_expect())?;
+ bundle.packdata()?.index(&odb)?;
+ let updated = patches::unbundle(&odb, &mut tx, REF_IT_BUNDLES, &rec)?;
+ for (name, oid) in updated {
+ up.insert(name, oid.into());
+ }
+ }
+ tx.commit()?;
+
+ Ok(Output { updated: up })
+}
diff --git a/src/cmd/id.rs b/src/cmd/id.rs
new file mode 100644
index 0000000..7504489
--- /dev/null
+++ b/src/cmd/id.rs
@@ -0,0 +1,188 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::BTreeSet,
+ num::NonZeroUsize,
+ path::PathBuf,
+};
+
+use anyhow::{
+ anyhow,
+ ensure,
+};
+use clap::ValueHint;
+use either::{
+ Either,
+ Left,
+ Right,
+};
+use url::Url;
+
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ },
+ git,
+ metadata::{
+ self,
+ git::META_FILE_ID,
+ IdentityId,
+ },
+ paths,
+};
+
+mod edit;
+pub use edit::{
+ edit,
+ Edit,
+};
+
+mod init;
+pub use init::{
+ init,
+ Init,
+};
+
+mod show;
+pub use show::{
+ show,
+ Show,
+};
+
+mod sign;
+pub use sign::{
+ sign,
+ Sign,
+};
+
+#[derive(Debug, clap::Subcommand)]
+#[allow(clippy::large_enum_variant)]
+pub enum Cmd {
+ /// Initialise a fresh identity
+ Init(Init),
+ /// Display the identity docment
+ Show(Show),
+ /// Edit the identity document
+ Edit(Edit),
+ /// Sign a proposed identity document
+ Sign(Sign),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Init(args) => init(args).map(cmd::IntoOutput::into_output),
+ Self::Show(args) => show(args).map(cmd::IntoOutput::into_output),
+ Self::Edit(args) => edit(args).map(cmd::IntoOutput::into_output),
+ Self::Sign(args) => sign(args).map(cmd::IntoOutput::into_output),
+ }
+ }
+}
+
+#[derive(Clone, Debug, clap::Args)]
+pub struct Common {
+ /// Path to the 'keyring' repository
+ // nb. not using from_global here -- current_dir doesn't make sense here as
+ // the default
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ env = "GIT_DIR",
+ default_value_os_t = paths::ids(),
+ value_hint = ValueHint::DirPath,
+ )]
+ git_dir: PathBuf,
+ /// Identity to operate on
+ ///
+ /// If not set as an option nor in the environment, the value of `it.id` in
+ /// the git config is tried.
+ #[clap(short = 'I', long = "identity", value_name = "ID", env = "IT_ID")]
+ id: Option<IdentityId>,
+}
+
+impl Common {
+ pub fn resolve(&self) -> cmd::Result<(git2::Repository, Refname)> {
+ let repo = git::repo::open(&self.git_dir)?;
+ let refname = identity_ref(
+ match self.id {
+ Some(id) => Left(id),
+ None => Right(repo.config()?),
+ }
+ .as_ref(),
+ )?;
+
+ Ok((repo, refname))
+ }
+}
+
+pub fn identity_ref(id: Either<&IdentityId, &git2::Config>) -> cmd::Result<Refname> {
+ let id = id.either(
+ |iid| Ok(iid.to_string()),
+ |cfg| {
+ cfg::git::identity(cfg)?
+ .ok_or_else(|| anyhow!("'{}' not set", cfg::git::IT_ID))
+ .map(|iid| iid.to_string())
+ },
+ )?;
+ Ok(Refname::try_from(format!("refs/heads/it/ids/{id}"))?)
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+struct Editable {
+ keys: metadata::KeySet<'static>,
+ threshold: NonZeroUsize,
+ mirrors: BTreeSet<Url>,
+ expires: Option<metadata::DateTime>,
+ custom: metadata::Custom,
+}
+
+impl From<metadata::Identity> for Editable {
+ fn from(
+ metadata::Identity {
+ keys,
+ threshold,
+ mirrors,
+ expires,
+ custom,
+ ..
+ }: metadata::Identity,
+ ) -> Self {
+ Self {
+ keys,
+ threshold,
+ mirrors,
+ expires,
+ custom,
+ }
+ }
+}
+
+impl TryFrom<Editable> for metadata::Identity {
+ type Error = crate::Error;
+
+ fn try_from(
+ Editable {
+ keys,
+ threshold,
+ mirrors,
+ expires,
+ custom,
+ }: Editable,
+ ) -> Result<Self, Self::Error> {
+ ensure!(!keys.is_empty(), "keys cannot be empty");
+
+ Ok(Self {
+ spec_version: crate::SPEC_VERSION,
+ prev: None,
+ keys,
+ threshold,
+ mirrors,
+ expires,
+ custom,
+ })
+ }
+}
diff --git a/src/cmd/id/edit.rs b/src/cmd/id/edit.rs
new file mode 100644
index 0000000..02687b8
--- /dev/null
+++ b/src/cmd/id/edit.rs
@@ -0,0 +1,209 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ fs::File,
+ iter,
+ path::Path,
+};
+
+use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+ Context,
+};
+
+use super::{
+ Common,
+ Editable,
+ META_FILE_ID,
+};
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ ui::{
+ self,
+ edit_commit_message,
+ edit_metadata,
+ info,
+ warn,
+ },
+ Aborted,
+ FromGit as _,
+ GitIdentity,
+ },
+ git::{
+ self,
+ refs,
+ },
+ json,
+ metadata::{
+ self,
+ Metadata,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+#[allow(rustdoc::bare_urls)]
+pub struct Edit {
+ #[clap(flatten)]
+ common: Common,
+ /// Commit to this branch to propose the update
+ ///
+ /// If not given, the edit is performed in-place if the signature threshold
+ /// is met using the supplied keys.
+ #[clap(long, value_parser)]
+ propose_as: Option<Refname>,
+ /// Check out the committed changes
+ ///
+ /// Only has an effect if the repository is non-bare.
+ #[clap(long, value_parser)]
+ checkout: bool,
+ /// Don't commit anything to disk
+ #[clap(long, value_parser)]
+ dry_run: bool,
+ /// Commit message for this edit
+ ///
+ /// Like git, $EDITOR will be invoked if not specified.
+ #[clap(short, long, value_parser)]
+ message: Option<String>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn edit(args: Edit) -> cmd::Result<Output> {
+ let (repo, refname) = args.common.resolve()?;
+
+ let GitIdentity {
+ hash: parent_hash,
+ signed: metadata::Signed { signed: parent, .. },
+ } = metadata::Identity::from_tip(&repo, &refname)?;
+
+ let mut id: metadata::Identity = edit_metadata(Editable::from(parent.clone()))?.try_into()?;
+ if id.canonicalise()? == parent.canonicalise()? {
+ info!("Document unchanged");
+ cmd::abort!();
+ }
+ id.prev = Some(parent_hash.clone());
+
+ let cfg = repo.config()?;
+ let mut signer = cfg::signer(&cfg, ui::askpass)?;
+ let keyid = metadata::KeyId::from(signer.ident());
+ ensure!(
+ parent.keys.contains_key(&keyid) || id.keys.contains_key(&keyid),
+ "signing key {keyid} is not eligible to sign the document"
+ );
+ let signed = Metadata::identity(&id).sign(iter::once(&mut signer))?;
+
+ let commit_to = match id.verify(&signed.signatures, cmd::find_parent(&repo)) {
+ Ok(_) => args.propose_as.as_ref().unwrap_or(&refname),
+ Err(metadata::error::Verification::SignatureThreshold) => match &args.propose_as {
+ None => bail!("cannot update {refname} in place as signature threshold is not met"),
+ Some(tgt) => {
+ warn!("Signature threshold is not met");
+ tgt
+ },
+ },
+ Err(e) => bail!(e),
+ };
+
+ let mut tx = refs::Transaction::new(&repo)?;
+
+ let _tip = tx.lock_ref(refname.clone())?;
+ let tip = repo.find_reference(_tip.name())?;
+ let parent_commit = tip.peel_to_commit()?;
+ let parent_tree = parent_commit.tree()?;
+ // check that parent is valid
+ {
+ let entry = parent_tree.get_name(META_FILE_ID).ok_or_else(|| {
+ anyhow!("{refname} was modified concurrently, {META_FILE_ID} not found in tree")
+ })?;
+ ensure!(
+ parent_hash == entry.to_object(&repo)?.peel_to_blob()?.id(),
+ "{refname} was modified concurrently",
+ );
+ }
+ let commit_to = tx.lock_ref(commit_to.clone())?;
+ let on_head =
+ !repo.is_bare() && git2::Branch::wrap(repo.find_reference(commit_to.name())?).is_head();
+
+ let tree = if on_head {
+ write_tree(&repo, &signed)
+ } else {
+ write_tree_bare(&repo, &signed, Some(&parent_tree))
+ }?;
+ let msg = args
+ .message
+ .map(Ok)
+ .unwrap_or_else(|| edit_commit_message(&repo, commit_to.name(), &parent_tree, &tree))?;
+ let commit = git::commit_signed(&mut signer, &repo, msg, &tree, &[&parent_commit])?;
+ commit_to.set_target(commit, "it: edit identity");
+
+ tx.commit()?;
+
+ if args.checkout && repo.is_bare() {
+ bail!("repository is bare, refusing checkout");
+ }
+ if args.checkout || on_head {
+ repo.checkout_tree(
+ tree.as_object(),
+ Some(git2::build::CheckoutBuilder::new().safe()),
+ )?;
+ repo.set_head(commit_to.name())?;
+ info!("Switched to branch '{commit_to}'");
+ }
+
+ Ok(Output {
+ refname: commit_to.into(),
+ commit,
+ })
+}
+
+pub(super) fn write_tree<'a>(
+ repo: &'a git2::Repository,
+ meta: &metadata::Signed<metadata::Metadata>,
+) -> crate::Result<git2::Tree<'a>> {
+ ensure!(
+ repo.statuses(None)?.is_empty(),
+ "uncommitted changes in working tree. Please commit or stash them before proceeding"
+ );
+ let id_json = repo
+ .workdir()
+ .expect("non-bare repo ought to have a workdir")
+ .join(META_FILE_ID);
+ let out = File::options()
+ .write(true)
+ .truncate(true)
+ .open(&id_json)
+ .with_context(|| format!("error opening {} for writing", id_json.display()))?;
+ serde_json::to_writer_pretty(&out, meta)
+ .with_context(|| format!("serialising to {} failed", id_json.display()))?;
+
+ let mut index = repo.index()?;
+ index.add_path(Path::new(META_FILE_ID))?;
+ let oid = index.write_tree()?;
+
+ Ok(repo.find_tree(oid)?)
+}
+
+pub(super) fn write_tree_bare<'a>(
+ repo: &'a git2::Repository,
+ meta: &metadata::Signed<metadata::Metadata>,
+ from: Option<&git2::Tree>,
+) -> crate::Result<git2::Tree<'a>> {
+ let blob = json::to_blob(repo, meta)?;
+ let mut bld = repo.treebuilder(from)?;
+ bld.insert(META_FILE_ID, blob, git2::FileMode::Blob.into())?;
+ let oid = bld.write()?;
+
+ Ok(repo.find_tree(oid)?)
+}
diff --git a/src/cmd/id/init.rs b/src/cmd/id/init.rs
new file mode 100644
index 0000000..a0ed119
--- /dev/null
+++ b/src/cmd/id/init.rs
@@ -0,0 +1,230 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::{
+ iter,
+ num::NonZeroUsize,
+};
+use std::path::PathBuf;
+
+use anyhow::ensure;
+use clap::ValueHint;
+use url::Url;
+
+use super::{
+ Editable,
+ META_FILE_ID,
+};
+use crate::{
+ cfg::{
+ self,
+ paths,
+ },
+ cmd::{
+ self,
+ args::Refname,
+ ui::{
+ self,
+ edit_metadata,
+ info,
+ },
+ },
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ },
+ json,
+ metadata::{
+ self,
+ DateTime,
+ Key,
+ KeySet,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Init {
+ /// Path to the 'keyring' repository
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ env = "GIT_DIR",
+ default_value_os_t = paths::ids(),
+ value_hint = ValueHint::DirPath,
+ )]
+ git_dir: PathBuf,
+ /// If the repository does not already exist, initialise it as non-bare
+ ///
+ /// Having the identity files checked out into a work tree may make it
+ /// easier to manipulate them with external tooling. Note, however, that
+ /// only committed files are considered by `it`.
+ #[clap(long, value_parser)]
+ no_bare: bool,
+ /// Set this identity as the default in the user git config
+ #[clap(long, value_parser)]
+ set_default: bool,
+ /// Additional public key to add to the identity; may be given multiple
+ /// times
+ #[clap(short, long, value_parser)]
+ public: Vec<Key<'static>>,
+ /// Threshold of keys required to sign the next revision
+ #[clap(long, value_parser)]
+ threshold: Option<NonZeroUsize>,
+ /// Alternate location where the identity history is published to; may be
+ /// given multiple times
+ #[clap(
+ long = "mirror",
+ value_parser,
+ value_name = "URL",
+ value_hint = ValueHint::Url,
+ )]
+ mirrors: Vec<Url>,
+ /// Optional date/time after which the current revision of the identity
+ /// should no longer be considered valid
+ #[clap(long, value_parser, value_name = "DATETIME")]
+ expires: Option<DateTime>,
+ /// Custom data
+ ///
+ /// The data must be parseable as canonical JSON, ie. not contain any
+ /// floating point values.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "FILE",
+ value_hint = ValueHint::FilePath,
+ )]
+ custom: Option<PathBuf>,
+ /// Stop for editing the metadata in $EDITOR
+ #[clap(long, value_parser)]
+ edit: bool,
+ /// Don't commit anything to disk
+ #[clap(long, value_parser)]
+ dry_run: bool,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ committed: Option<Committed>,
+ data: metadata::Signed<metadata::Metadata<'static>>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Committed {
+ repo: PathBuf,
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn init(args: Init) -> cmd::Result<Output> {
+ let git_dir = args.git_dir;
+ info!("Initialising fresh identity at {}", git_dir.display());
+
+ let custom = args.custom.map(json::load).transpose()?.unwrap_or_default();
+ let cfg = git2::Config::open_default()?;
+ let mut signer = cfg::signer(&cfg, ui::askpass)?;
+ let threshold = match args.threshold {
+ None => NonZeroUsize::new(1)
+ .unwrap()
+ .saturating_add(args.public.len() / 2),
+ Some(t) => {
+ ensure!(
+ t.get() < args.public.len(),
+ "threshold must be smaller than the number of keys"
+ );
+ t
+ },
+ };
+
+ let signer_id = signer.ident().to_owned();
+ let keys = iter::once(signer_id.clone())
+ .map(metadata::Key::from)
+ .chain(args.public)
+ .collect::<KeySet>();
+
+ let meta = {
+ let id = metadata::Identity {
+ spec_version: crate::SPEC_VERSION,
+ prev: None,
+ keys,
+ threshold,
+ mirrors: args.mirrors.into_iter().collect(),
+ expires: args.expires,
+ custom,
+ };
+
+ if args.edit {
+ edit_metadata(Editable::from(id))?.try_into()?
+ } else {
+ id
+ }
+ };
+ let sigid = metadata::IdentityId::try_from(&meta).unwrap();
+ let signed = metadata::Metadata::identity(meta).sign(iter::once(&mut signer))?;
+
+ let out = if !args.dry_run {
+ let id_ref = Refname::try_from(format!("refs/heads/it/ids/{}", sigid)).unwrap();
+ let repo = git::repo::open_or_init(
+ git_dir,
+ git::repo::InitOpts {
+ bare: !args.no_bare,
+ description: "`it` keyring",
+ initial_head: &id_ref,
+ },
+ )?;
+
+ let mut tx = refs::Transaction::new(&repo)?;
+ let id_ref = tx.lock_ref(id_ref)?;
+ ensure!(
+ if_not_found_none(repo.refname_to_id(id_ref.name()))?.is_none(),
+ "{id_ref} already exists",
+ );
+
+ let blob = json::to_blob(&repo, &signed)?;
+ let tree = {
+ let mut bld = repo.treebuilder(None)?;
+ bld.insert(META_FILE_ID, blob, git2::FileMode::Blob.into())?;
+ let oid = bld.write()?;
+ repo.find_tree(oid)?
+ };
+ let msg = format!("Create identity {}", sigid);
+ let oid = git::commit_signed(&mut signer, &repo, msg, &tree, &[])?;
+ id_ref.set_target(oid, "it: create");
+
+ let mut cfg = repo.config()?;
+ cfg.set_str(
+ cfg::git::USER_SIGNING_KEY,
+ &format!("key::{}", signer_id.to_openssh()?),
+ )?;
+ let idstr = sigid.to_string();
+ cfg.set_str(cfg::git::IT_ID, &idstr)?;
+ if args.set_default {
+ cfg.open_global()?.set_str(cfg::git::IT_ID, &idstr)?;
+ }
+
+ tx.commit()?;
+ if !repo.is_bare() {
+ repo.checkout_head(None).ok();
+ }
+
+ Output {
+ committed: Some(Committed {
+ repo: repo.path().to_owned(),
+ refname: id_ref.into(),
+ commit: oid,
+ }),
+ data: signed,
+ }
+ } else {
+ Output {
+ committed: None,
+ data: signed,
+ }
+ };
+
+ Ok(out)
+}
diff --git a/src/cmd/id/show.rs b/src/cmd/id/show.rs
new file mode 100644
index 0000000..4a25455
--- /dev/null
+++ b/src/cmd/id/show.rs
@@ -0,0 +1,75 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::path::PathBuf;
+
+use super::Common;
+use crate::{
+ cmd::{
+ self,
+ args::Refname,
+ FromGit as _,
+ GitIdentity,
+ },
+ metadata::{
+ self,
+ ContentHash,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Show {
+ #[clap(flatten)]
+ common: Common,
+ /// Blob hash to show
+ ///
+ /// Instead of looking for an id.json in the tree --ref points to, load a
+ /// particular id.json by hash. If given, --ref is ignored.
+ #[clap(long = "hash", value_parser, value_name = "OID")]
+ blob_hash: Option<git2::Oid>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ repo: PathBuf,
+ #[serde(rename = "ref")]
+ refname: Refname,
+ hash: ContentHash,
+ status: Status,
+ data: metadata::Signed<metadata::Identity>,
+}
+
+#[derive(serde::Serialize)]
+#[serde(rename_all = "UPPERCASE")]
+pub enum Status {
+ Verified {
+ id: metadata::IdentityId,
+ },
+ #[serde(with = "crate::serde::display")]
+ Invalid(metadata::error::Verification),
+}
+
+impl From<Result<metadata::IdentityId, metadata::error::Verification>> for Status {
+ fn from(r: Result<metadata::IdentityId, metadata::error::Verification>) -> Self {
+ r.map(|id| Self::Verified { id })
+ .unwrap_or_else(Self::Invalid)
+ }
+}
+
+pub fn show(args: Show) -> cmd::Result<Output> {
+ let (repo, refname) = args.common.resolve()?;
+
+ let GitIdentity { hash, signed } = match args.blob_hash {
+ None => metadata::Identity::from_tip(&repo, &refname)?,
+ Some(oid) => metadata::Identity::from_blob(&repo.find_blob(oid)?)?,
+ };
+ let status = signed.verify(cmd::find_parent(&repo)).into();
+
+ Ok(Output {
+ repo: repo.path().to_owned(),
+ refname,
+ hash,
+ status,
+ data: signed,
+ })
+}
diff --git a/src/cmd/id/sign.rs b/src/cmd/id/sign.rs
new file mode 100644
index 0000000..b63ef94
--- /dev/null
+++ b/src/cmd/id/sign.rs
@@ -0,0 +1,221 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::collections::BTreeMap;
+
+use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+ Context as _,
+};
+
+use super::{
+ edit,
+ Common,
+};
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ args::Refname,
+ id::META_FILE_ID,
+ ui::{
+ self,
+ edit_commit_message,
+ info,
+ },
+ FromGit as _,
+ GitIdentity,
+ },
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ },
+ metadata,
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Sign {
+ #[clap(flatten)]
+ common: Common,
+ /// Commit to this branch if the signature threshold is met
+ #[clap(short = 'b', long, value_parser, value_name = "REF")]
+ commit_to: Refname,
+ /// Check out the committed changes
+ ///
+ /// Only has an effect if the repository is non-bare.
+ #[clap(long, value_parser)]
+ checkout: bool,
+ /// Don't commit anything to disk
+ #[clap(long, value_parser)]
+ dry_run: bool,
+ /// Commit message for this edit
+ ///
+ /// Like git, $EDITOR will be invoked if not specified.
+ #[clap(short, long, value_parser)]
+ message: Option<String>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ #[serde(rename = "ref")]
+ refname: Refname,
+ #[serde(with = "crate::git::serde::oid")]
+ commit: git2::Oid,
+}
+
+pub fn sign(args: Sign) -> cmd::Result<Output> {
+ let (repo, refname) = args.common.resolve()?;
+ let mut tx = refs::Transaction::new(&repo)?;
+ let _tip = tx.lock_ref(refname.clone())?;
+
+ let GitIdentity {
+ signed:
+ metadata::Signed {
+ signed: proposed,
+ signatures: proposed_signatures,
+ },
+ ..
+ } = metadata::Identity::from_tip(&repo, &refname)?;
+ let prev_hash: git2::Oid = proposed
+ .prev
+ .as_ref()
+ .ok_or_else(|| anyhow!("cannot sign a genesis revision"))?
+ .into();
+ let (parent, target_ref) = if refname == args.commit_to {
+ // Signing in-place is only legal if the proposed update already
+ // meets the signature threshold
+ let _ = proposed
+ .verify(&proposed_signatures, cmd::find_parent(&repo))
+ .context("proposed update does not meet the signature threshold")?;
+ (proposed.clone(), repo.find_reference(&args.commit_to)?)
+ } else {
+ let target_ref = if_not_found_none(repo.find_reference(&args.commit_to))?;
+ match target_ref {
+ // If the target ref exists, it must yield a verified id.json whose
+ // blob hash equals the 'prev' hash of the proposed update
+ Some(tgt) => {
+ let parent_commit = tgt.peel_to_commit()?;
+ let GitIdentity {
+ hash: parent_hash,
+ signed:
+ metadata::Signed {
+ signed: parent,
+ signatures: parent_signatures,
+ },
+ } = metadata::Identity::from_commit(&repo, &parent_commit).with_context(|| {
+ format!("failed to load {} from {}", META_FILE_ID, &args.commit_to)
+ })?;
+ let _ = parent
+ .verify(&parent_signatures, cmd::find_parent(&repo))
+ .with_context(|| format!("target {} could not be verified", &args.commit_to))?;
+ ensure!(
+ parent_hash == prev_hash,
+ "parent hash (.prev) doesn't match"
+ );
+
+ (parent, tgt)
+ },
+
+ // If the target ref is unborn, the proposed's parent commit must
+ // yield a verified id.json, as we will create the target from
+ // HEAD^1
+ None => {
+ let parent_commit = repo
+ .find_reference(&refname)?
+ .peel_to_commit()?
+ .parents()
+ .next()
+ .ok_or_else(|| anyhow!("cannot sign an initial commit"))?;
+ let GitIdentity {
+ hash: parent_hash,
+ signed:
+ metadata::Signed {
+ signed: parent,
+ signatures: parent_signatures,
+ },
+ } = metadata::Identity::from_commit(&repo, &parent_commit)?;
+ let _ = parent
+ .verify(&parent_signatures, cmd::find_parent(&repo))
+ .with_context(|| {
+ format!(
+ "parent commit {} of {} could not be verified",
+ parent_commit.id(),
+ refname
+ )
+ })?;
+ ensure!(
+ parent_hash == prev_hash,
+ "parent hash (.prev) doesn't match"
+ );
+
+ let tgt = repo.reference(
+ &args.commit_to,
+ parent_commit.id(),
+ false,
+ &format!("branch: Created from {}^1", refname),
+ )?;
+
+ (parent, tgt)
+ },
+ }
+ };
+ let commit_to = tx.lock_ref(args.commit_to)?;
+
+ let canonical = proposed.canonicalise()?;
+ let mut signer = cfg::signer(&repo.config()?, ui::askpass)?;
+ let mut signatures = BTreeMap::new();
+ let keyid = metadata::KeyId::from(signer.ident());
+ if !parent.keys.contains_key(&keyid) && !proposed.keys.contains_key(&keyid) {
+ bail!("key {} is not eligible to sign the document", keyid);
+ }
+ if proposed_signatures.contains_key(&keyid) {
+ bail!("proposed update is already signed with key {}", keyid);
+ }
+
+ let signature = signer.sign(&canonical)?;
+ signatures.insert(keyid, metadata::Signature::from(signature));
+ signatures.extend(proposed_signatures);
+
+ let _ = proposed
+ .verify(&signatures, cmd::find_parent(&repo))
+ .context("proposal could not be verified after signing")?;
+
+ let signed = metadata::Signed {
+ signed: metadata::Metadata::identity(proposed),
+ signatures,
+ };
+
+ let parent_commit = target_ref.peel_to_commit()?;
+ let parent_tree = parent_commit.tree()?;
+ let on_head = !repo.is_bare() && git2::Branch::wrap(target_ref).is_head();
+
+ let tree = if on_head {
+ edit::write_tree(&repo, &signed)
+ } else {
+ edit::write_tree_bare(&repo, &signed, Some(&parent_tree))
+ }?;
+ let msg = args
+ .message
+ .map(Ok)
+ .unwrap_or_else(|| edit_commit_message(&repo, commit_to.name(), &parent_tree, &tree))?;
+ let commit = git::commit_signed(&mut signer, &repo, msg, &tree, &[&parent_commit])?;
+ commit_to.set_target(commit, "it: identity signoff");
+
+ tx.commit()?;
+
+ if on_head {
+ repo.checkout_tree(
+ tree.as_object(),
+ Some(git2::build::CheckoutBuilder::new().safe()),
+ )?;
+ info!("Checked out tree {}", tree.id());
+ }
+
+ Ok(Output {
+ refname: commit_to.into(),
+ commit,
+ })
+}
diff --git a/src/cmd/mergepoint.rs b/src/cmd/mergepoint.rs
new file mode 100644
index 0000000..2bf4f79
--- /dev/null
+++ b/src/cmd/mergepoint.rs
@@ -0,0 +1,75 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::{
+ cmd::{
+ self,
+ patch,
+ },
+ patches,
+};
+
+#[derive(Debug, clap::Subcommand)]
+pub enum Cmd {
+ /// Record a mergepoint in a local repository
+ Record(Record),
+ /// Submit a mergepoint to a remote drop
+ Submit(Submit),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Record(args) => record(args),
+ Self::Submit(args) => submit(args),
+ }
+ .map(cmd::IntoOutput::into_output)
+ }
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Record {
+ #[clap(flatten)]
+ common: patch::Common,
+ /// Allow branches to be uneven with their upstream (if any)
+ #[clap(long, visible_alias = "force", value_parser)]
+ ignore_upstream: bool,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Submit {
+ #[clap(flatten)]
+ common: patch::Common,
+ #[clap(flatten)]
+ remote: patch::Remote,
+ /// Allow branches to be uneven with their upstream (if any)
+ #[clap(long, visible_alias = "force", value_parser)]
+ ignore_upstream: bool,
+}
+
+pub fn record(
+ Record {
+ common,
+ ignore_upstream,
+ }: Record,
+) -> cmd::Result<patches::Record> {
+ patch::create(patch::Kind::Merges {
+ common,
+ remote: None,
+ force: ignore_upstream,
+ })
+}
+
+pub fn submit(
+ Submit {
+ common,
+ remote,
+ ignore_upstream,
+ }: Submit,
+) -> cmd::Result<patches::Record> {
+ patch::create(patch::Kind::Merges {
+ common,
+ remote: Some(remote),
+ force: ignore_upstream,
+ })
+}
diff --git a/src/cmd/patch.rs b/src/cmd/patch.rs
new file mode 100644
index 0000000..a1b781d
--- /dev/null
+++ b/src/cmd/patch.rs
@@ -0,0 +1,77 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::{
+ cmd,
+ patches,
+};
+
+mod create;
+mod prepare;
+
+pub use create::{
+ create,
+ Comment,
+ Common,
+ Kind,
+ Patch,
+ Remote,
+};
+
+#[derive(Debug, clap::Subcommand)]
+pub enum Cmd {
+ /// Record a patch in a local drop history
+ Record(Record),
+ /// Submit a patch to a remote drop
+ Submit(Submit),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Record(args) => record(args),
+ Self::Submit(args) => submit(args),
+ }
+ .map(cmd::IntoOutput::into_output)
+ }
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Record {
+ #[clap(flatten)]
+ common: Common,
+ #[clap(flatten)]
+ patch: Patch,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Submit {
+ #[clap(flatten)]
+ common: Common,
+ #[clap(flatten)]
+ patch: Patch,
+ #[clap(flatten)]
+ remote: Remote,
+}
+
+pub fn record(Record { common, patch }: Record) -> cmd::Result<patches::Record> {
+ create(Kind::Patch {
+ common,
+ remote: None,
+ patch,
+ })
+}
+
+pub fn submit(
+ Submit {
+ common,
+ patch,
+ remote,
+ }: Submit,
+) -> cmd::Result<patches::Record> {
+ create(Kind::Patch {
+ common,
+ remote: Some(remote),
+ patch,
+ })
+}
diff --git a/src/cmd/patch/create.rs b/src/cmd/patch/create.rs
new file mode 100644
index 0000000..7527364
--- /dev/null
+++ b/src/cmd/patch/create.rs
@@ -0,0 +1,483 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ borrow::Cow,
+ collections::BTreeMap,
+ env,
+ path::PathBuf,
+};
+
+use anyhow::anyhow;
+use clap::ValueHint;
+use globset::{
+ GlobSet,
+ GlobSetBuilder,
+};
+use once_cell::sync::Lazy;
+use url::Url;
+
+use super::prepare;
+use crate::{
+ cfg,
+ cmd::{
+ self,
+ ui::{
+ self,
+ debug,
+ info,
+ },
+ util::args::IdSearchPath,
+ Aborted,
+ },
+ git::{
+ self,
+ Refname,
+ },
+ metadata::IdentityId,
+ patches::{
+ self,
+ iter,
+ DropHead,
+ Topic,
+ TrackingBranch,
+ GLOB_IT_BUNDLES,
+ GLOB_IT_IDS,
+ GLOB_IT_TOPICS,
+ REF_HEADS_PATCHES,
+ REF_IT_BUNDLES,
+ REF_IT_PATCHES,
+ REF_IT_SEEN,
+ },
+ paths,
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Common {
+ /// Path to the drop repository
+ #[clap(from_global)]
+ git_dir: PathBuf,
+ /// Path to the source repository
+ ///
+ /// If set, the patch bundle will be created from objects residing in an
+ /// external repository. The main use case for this is to allow a bare
+ /// drop to pull in checkpoints from a local repo with a regular layout
+ /// (ie. non it-aware).
+ #[clap(
+ long = "source-dir",
+ alias = "src-dir",
+ value_parser,
+ value_name = "DIR",
+ value_hint = ValueHint::DirPath,
+ )]
+ src_dir: Option<PathBuf>,
+ /// Identity to assume
+ ///
+ /// If not set as an option nor in the environment, the value of `it.id` in
+ /// the git config is tried.
+ #[clap(short = 'I', long = "identity", value_name = "ID", env = "IT_ID")]
+ id: Option<IdentityId>,
+ /// A list of paths to search for identity repositories
+ #[clap(
+ long,
+ value_parser,
+ value_name = "PATH",
+ env = "IT_ID_PATH",
+ default_value_t,
+ value_hint = ValueHint::DirPath,
+ )]
+ id_path: IdSearchPath,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// IPFS API to publish the patch bundle to
+ ///
+ /// Currently has no effect when submitting a patch to a remote drop. When
+ /// running `ipfs daemon`, the default API address is 'http://127.0.0.1:5001'.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "URL",
+ value_hint = ValueHint::Url,
+ )]
+ ipfs_api: Option<Url>,
+ /// Additional identities to include, eg. to allow commit verification
+ #[clap(long = "add-id", value_parser, value_name = "ID")]
+ ids: Vec<IdentityId>,
+ /// Message to attach to the patch (cover letter, comment)
+ ///
+ /// If not set, $EDITOR will be invoked to author one.
+ #[clap(short, long, value_parser, value_name = "STRING")]
+ message: Option<String>,
+ /// Create the patch, but stop short of submitting / recording it
+ #[clap(long, value_parser)]
+ dry_run: bool,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Remote {
+ /// Url to submit the patch to
+ ///
+ /// Usually one of the alternates from the drop metadata. If not set,
+ /// GIT_DIR is assumed to contain a drop with which the patch can be
+ /// recorded without any network access.
+ #[clap(long, visible_alias = "submit-to", value_parser, value_name = "URL")]
+ url: Url,
+ /// Refname of the drop to record the patch with
+ ///
+ /// We need to pick a local (remote-tracking) drop history in order to
+ /// compute delta bases for the patch. The value is interpreted
+ /// according to "DWIM" rules, i.e. shorthand forms like 'it/patches',
+ /// 'origin/patches' are attempted to be resolved.
+ #[clap(long = "drop", value_parser, value_name = "STRING")]
+ drop_ref: String,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Patch {
+ /// Base branch the patch is against
+ ///
+ /// If --topic is given, the branch must exist in the patch bundle
+ /// --reply-to refers to, or the default entry to reply to on that
+ /// topic. Otherwise, the branch must exist in the drop
+ /// metadata. Shorthand branch names are accepted.
+ ///
+ /// If not given, "main" or "master" is tried, in that order.
+ #[clap(long = "base", value_parser, value_name = "REF")]
+ base: Option<String>,
+ /// Head revision of the patch, in 'git rev-parse' syntax
+ #[clap(
+ long = "head",
+ value_parser,
+ value_name = "REVSPEC",
+ default_value = "HEAD"
+ )]
+ head: String,
+ /// Post the patch to a previously recorded topic
+ #[clap(long, value_parser, value_name = "TOPIC")]
+ topic: Option<Topic>,
+ /// Reply to a particular entry within a topic
+ ///
+ /// Only considered if --topic is given.
+ #[clap(long, value_parser, value_name = "ID")]
+ reply_to: Option<git2::Oid>,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Comment {
+ /// The topic to comment on
+ #[clap(value_parser, value_name = "TOPIC")]
+ topic: Topic,
+ /// Reply to a particular entry within the topic
+ #[clap(long, value_parser, value_name = "ID")]
+ reply_to: Option<git2::Oid>,
+}
+
+pub enum Kind {
+ Merges {
+ common: Common,
+ remote: Option<Remote>,
+ force: bool,
+ },
+ Snapshot {
+ common: Common,
+ },
+ Comment {
+ common: Common,
+ remote: Option<Remote>,
+ comment: Comment,
+ },
+ Patch {
+ common: Common,
+ remote: Option<Remote>,
+ patch: Patch,
+ },
+}
+
+impl Kind {
+ fn common(&self) -> &Common {
+ match self {
+ Self::Merges { common, .. }
+ | Self::Snapshot { common }
+ | Self::Comment { common, .. }
+ | Self::Patch { common, .. } => common,
+ }
+ }
+
+ fn remote(&self) -> Option<&Remote> {
+ match self {
+ Self::Merges { remote, .. }
+ | Self::Comment { remote, .. }
+ | Self::Patch { remote, .. } => remote.as_ref(),
+ Self::Snapshot { .. } => None,
+ }
+ }
+
+ fn accept_options(&self, drop: &DropHead) -> patches::AcceptOptions {
+ let mut options = patches::AcceptOptions::default();
+ match self {
+ Self::Merges { common, .. } => {
+ options.allow_fat_pack = true;
+ options.max_branches = drop.meta.roles.branches.len();
+ options.max_refs = options.max_branches + common.ids.len() + 1;
+ options.max_commits = 100_000;
+ },
+ Self::Snapshot { .. } => {
+ options.allow_fat_pack = true;
+ options.allowed_refs = SNAPSHOT_REFS.clone();
+ options.max_branches = usize::MAX;
+ options.max_refs = usize::MAX;
+ options.max_commits = usize::MAX;
+ options.max_notes = usize::MAX;
+ options.max_tags = usize::MAX;
+ },
+
+ _ => {},
+ }
+
+ options
+ }
+}
+
+struct Resolved {
+ repo: prepare::Repo,
+ signer_id: IdentityId,
+ bundle_dir: PathBuf,
+}
+
+impl Common {
+ fn resolve(&self) -> cmd::Result<Resolved> {
+ let drp = git::repo::open(&self.git_dir)?;
+ let ids = self.id_path.open_git();
+ let src = match self.src_dir.as_ref() {
+ None => {
+ let cwd = env::current_dir()?;
+ (cwd != self.git_dir).then_some(cwd)
+ },
+ Some(dir) => Some(dir.to_owned()),
+ }
+ .as_deref()
+ .map(git::repo::open_bare)
+ .transpose()?;
+
+ debug!(
+ "drop: {}, src: {:?}, ids: {:?}",
+ drp.path().display(),
+ src.as_ref().map(|r| r.path().display()),
+ env::join_paths(ids.iter().map(|r| r.path()))
+ );
+
+ // IT_ID_PATH could differ from what was used at initialisation
+ git::add_alternates(&drp, &ids)?;
+
+ let repo = prepare::Repo::new(drp, ids, src);
+ let signer_id = match self.id {
+ Some(id) => id,
+ None => cfg::git::identity(&repo.source().config()?)?
+ .ok_or_else(|| anyhow!("no identity configured for signer"))?,
+ };
+ let bundle_dir = if self.bundle_dir.is_absolute() {
+ self.bundle_dir.clone()
+ } else {
+ repo.target().path().join(&self.bundle_dir)
+ };
+
+ Ok(Resolved {
+ repo,
+ signer_id,
+ bundle_dir,
+ })
+ }
+}
+
+static SNAPSHOT_REFS: Lazy<GlobSet> = Lazy::new(|| {
+ GlobSetBuilder::new()
+ .add(GLOB_IT_TOPICS.clone())
+ .add(GLOB_IT_BUNDLES.clone())
+ .add(GLOB_IT_IDS.clone())
+ .build()
+ .unwrap()
+});
+
+pub fn create(args: Kind) -> cmd::Result<patches::Record> {
+ let Resolved {
+ repo,
+ signer_id,
+ bundle_dir,
+ } = args.common().resolve()?;
+ let drop_ref: Cow<str> = match args.remote() {
+ Some(remote) => {
+ let full = repo
+ .source()
+ .resolve_reference_from_short_name(&remote.drop_ref)?;
+ full.name()
+ .ok_or_else(|| anyhow!("invalid drop ref"))?
+ .to_owned()
+ .into()
+ },
+ None if repo.target().is_bare() => REF_HEADS_PATCHES.into(),
+ None => REF_IT_PATCHES.into(),
+ };
+
+ let mut signer = cfg::git::signer(&repo.source().config()?, ui::askpass)?;
+ let drop = patches::DropHead::from_refname(repo.target(), &drop_ref)?;
+
+ let spec = match &args {
+ Kind::Merges { force, .. } => prepare::Kind::Mergepoint { force: *force },
+ Kind::Snapshot { .. } => prepare::Kind::Snapshot { incremental: true },
+ Kind::Comment { comment, .. } => prepare::Kind::Comment {
+ topic: comment.topic.clone(),
+ reply: comment.reply_to,
+ },
+ Kind::Patch { patch, .. } => {
+ let (name, base_ref) = dwim_base(
+ repo.target(),
+ &drop,
+ patch.topic.as_ref(),
+ patch.reply_to,
+ patch.base.as_deref(),
+ )?
+ .ok_or_else(|| anyhow!("unable to determine base branch"))?;
+ let base = repo
+ .target()
+ .find_reference(&base_ref)?
+ .peel_to_commit()?
+ .id();
+ let head = repo
+ .source()
+ .revparse_single(&patch.head)?
+ .peel_to_commit()?
+ .id();
+
+ prepare::Kind::Patch {
+ head,
+ base,
+ name,
+ re: patch.topic.as_ref().map(|t| (t.clone(), patch.reply_to)),
+ }
+ },
+ };
+
+ let mut patch = prepare::Preparator::new(
+ &repo,
+ &drop,
+ prepare::Submitter {
+ signer: &mut signer,
+ id: signer_id,
+ },
+ )
+ .prepare_patch(
+ &bundle_dir,
+ spec,
+ args.common().message.clone(),
+ &args.common().ids,
+ )?;
+
+ if args.common().dry_run {
+ info!("--dry-run given, stopping here");
+ cmd::abort!();
+ }
+
+ match args.remote() {
+ Some(remote) => patch.submit(remote.url.clone()),
+ None => patch.try_accept(patches::AcceptArgs {
+ unbundle_prefix: REF_IT_BUNDLES,
+ drop_ref: &drop_ref,
+ seen_ref: REF_IT_SEEN,
+ repo: repo.target(),
+ signer: &mut signer,
+ ipfs_api: args.common().ipfs_api.as_ref(),
+ options: args.accept_options(&drop),
+ }),
+ }
+}
+
+fn dwim_base(
+ repo: &git2::Repository,
+ drop: &DropHead,
+ topic: Option<&Topic>,
+ reply_to: Option<git2::Oid>,
+ base: Option<&str>,
+) -> cmd::Result<Option<(Refname, Refname)>> {
+ let mut candidates = BTreeMap::new();
+ match topic {
+ Some(topic) => {
+ let reply_to = reply_to.map(Ok).unwrap_or_else(|| {
+ iter::topic::default_reply_to(repo, topic)?
+ .ok_or_else(|| anyhow!("topic {topic} not found"))
+ })?;
+ let mut patch_id = None;
+ for note in iter::topic(repo, topic) {
+ let note = note?;
+ if note.header.id == reply_to {
+ patch_id = Some(note.header.patch.id);
+ break;
+ }
+ }
+ let patch_id = patch_id.ok_or_else(|| {
+ anyhow!("no patch found corresponding to topic: {topic}, reply-to: {reply_to}")
+ })?;
+
+ let prefix = format!("{REF_IT_BUNDLES}/{patch_id}/");
+ let mut iter = repo.references_glob(&format!("{prefix}**"))?;
+ for candidate in iter.names() {
+ let candidate = candidate?;
+ if let Some(suf) = candidate.strip_prefix(&prefix) {
+ if !suf.starts_with("it/") {
+ candidates.insert(format!("refs/{suf}"), candidate.parse()?);
+ }
+ }
+ }
+ },
+
+ None => candidates.extend(
+ drop.meta
+ .roles
+ .branches
+ .keys()
+ .cloned()
+ .map(|name| (name.to_string(), name)),
+ ),
+ };
+
+ const FMTS: &[fn(&str) -> String] = &[
+ |s| s.to_owned(),
+ |s| format!("refs/{}", s),
+ |s| format!("refs/heads/{}", s),
+ |s| format!("refs/tags/{}", s),
+ ];
+
+ debug!("dwim candidates: {candidates:#?}");
+
+ match base {
+ Some(base) => {
+ for (virt, act) in candidates {
+ for f in FMTS {
+ let name = f(base);
+ if name == virt {
+ let refname = name.parse()?;
+ return Ok(Some((refname, act)));
+ }
+ }
+ }
+ Ok(None)
+ },
+
+ // nb. biased towards "main" because we use a BTreeMap
+ None => Ok(candidates.into_iter().find_map(|(k, _)| match k.as_str() {
+ "refs/heads/main" => Some((Refname::main(), TrackingBranch::main().into_refname())),
+ "refs/heads/master" => {
+ Some((Refname::master(), TrackingBranch::master().into_refname()))
+ },
+ _ => None,
+ })),
+ }
+}
diff --git a/src/cmd/patch/prepare.rs b/src/cmd/patch/prepare.rs
new file mode 100644
index 0000000..06d5ec9
--- /dev/null
+++ b/src/cmd/patch/prepare.rs
@@ -0,0 +1,615 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::path::{
+ Path,
+ PathBuf,
+};
+
+use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+};
+use either::Either::Left;
+use sha2::{
+ Digest,
+ Sha256,
+};
+
+use crate::{
+ bundle,
+ cmd::{
+ self,
+ ui::{
+ debug,
+ edit_comment,
+ edit_cover_letter,
+ info,
+ warn,
+ },
+ },
+ git::{
+ self,
+ if_not_found_none,
+ Refname,
+ },
+ keys::Signer,
+ metadata::{
+ self,
+ git::{
+ FromGit,
+ GitMeta,
+ META_FILE_ID,
+ },
+ identity::{
+ self,
+ IdentityId,
+ },
+ ContentHash,
+ KeyId,
+ },
+ patches::{
+ self,
+ iter::{
+ dropped,
+ topic,
+ },
+ notes,
+ record,
+ Topic,
+ REF_IT_BUNDLES,
+ REF_IT_PATCHES,
+ TOPIC_MERGES,
+ TOPIC_SNAPSHOTS,
+ },
+};
+
+pub enum Kind {
+ Mergepoint {
+ force: bool,
+ },
+ Snapshot {
+ incremental: bool,
+ },
+ Patch {
+ head: git2::Oid,
+ base: git2::Oid,
+ name: Refname,
+ re: Option<(Topic, Option<git2::Oid>)>,
+ },
+ Comment {
+ topic: Topic,
+ reply: Option<git2::Oid>,
+ },
+}
+
+pub struct Submitter<'a, S: ?Sized> {
+ pub signer: &'a mut S,
+ pub id: IdentityId,
+}
+
+pub struct Repo {
+ drp: git2::Repository,
+ src: Option<git2::Repository>,
+ ids: Vec<git2::Repository>,
+}
+
+impl Repo {
+ pub fn new(
+ drp: git2::Repository,
+ ids: Vec<git2::Repository>,
+ src: Option<git2::Repository>,
+ ) -> Self {
+ Self { drp, ids, src }
+ }
+
+ /// Repository containing the patch objects
+ pub fn source(&self) -> &git2::Repository {
+ self.src.as_ref().unwrap_or(&self.drp)
+ }
+
+ /// Repository containing the drop state
+ pub fn target(&self) -> &git2::Repository {
+ &self.drp
+ }
+
+ /// Repositories containing identity histories
+ pub fn id_path(&self) -> &[git2::Repository] {
+ &self.ids
+ }
+}
+
+pub struct Preparator<'a, S: ?Sized> {
+ repo: &'a Repo,
+ drop: &'a patches::DropHead<'a>,
+ submitter: Submitter<'a, S>,
+}
+
+impl<'a, S: Signer> Preparator<'a, S> {
+ pub fn new(
+ repo: &'a Repo,
+ drop: &'a patches::DropHead<'a>,
+ submitter: Submitter<'a, S>,
+ ) -> Self {
+ Self {
+ repo,
+ drop,
+ submitter,
+ }
+ }
+
+ pub fn prepare_patch(
+ &mut self,
+ bundle_dir: &Path,
+ kind: Kind,
+ message: Option<String>,
+ additional_ids: &[IdentityId],
+ ) -> cmd::Result<patches::Submission> {
+ let mut header = bundle::Header::default();
+
+ match kind {
+ Kind::Mergepoint { force } => {
+ mergepoint(self.repo, &self.drop.meta, &mut header, force)?;
+ ensure!(
+ !header.references.is_empty(),
+ "refusing to create empty checkpoint"
+ );
+ self.annotate_checkpoint(&mut header, &TOPIC_MERGES, message)?;
+ },
+ Kind::Snapshot { incremental } => {
+ snapshot(self.repo, &mut header, incremental)?;
+ ensure!(
+ !header.references.is_empty(),
+ "refusing to create empty snapshot"
+ );
+ self.annotate_checkpoint(&mut header, &TOPIC_SNAPSHOTS, message)?;
+ },
+ Kind::Patch {
+ head,
+ base,
+ name,
+ re,
+ } => {
+ ensure!(base != head, "refusing to create empty patch");
+ ensure!(
+ if_not_found_none(self.repo.source().merge_base(base, head))?.is_some(),
+ "{base} is not reachable from {head}"
+ );
+ info!("Adding patch for {name}: {base}..{head}");
+ header.add_prerequisite(&base);
+ header.add_reference(name, &head);
+ self.annotate_patch(&mut header, message, re)?;
+ },
+ Kind::Comment { topic, reply } => {
+ self.annotate_comment(&mut header, topic, message, reply)?;
+ },
+ }
+
+ for id in additional_ids {
+ Identity::find(
+ self.repo.target(),
+ &self.drop.ids,
+ self.repo.id_path(),
+ cmd::id::identity_ref(Left(id))?,
+ )?
+ .update(&mut header);
+ }
+
+ let signer_hash = {
+ let keyid = self.submitter.signer.ident().keyid();
+ let id_ref = cmd::id::identity_ref(Left(&self.submitter.id))?;
+ let id = Identity::find(
+ self.repo.target(),
+ &self.drop.ids,
+ self.repo.id_path(),
+ id_ref,
+ )?;
+ ensure!(
+ id.contains(&keyid),
+ "signing key {keyid} not in identity {}",
+ id.id()
+ );
+ id.update(&mut header);
+
+ id.hash().clone()
+ };
+
+ let bundle = patches::Bundle::create(bundle_dir, self.repo.source(), header)?;
+ let signature = bundle
+ .sign(self.submitter.signer)
+ .map(|signature| patches::Signature {
+ signer: signer_hash,
+ signature: signature.into(),
+ })?;
+
+ Ok(patches::Submission { signature, bundle })
+ }
+
+ fn annotate_checkpoint(
+ &mut self,
+ bundle: &mut bundle::Header,
+ topic: &Topic,
+ message: Option<String>,
+ ) -> cmd::Result<()> {
+ let kind = if topic == &*TOPIC_MERGES {
+ notes::CheckpointKind::Merge
+ } else if topic == &*TOPIC_SNAPSHOTS {
+ notes::CheckpointKind::Snapshot
+ } else {
+ bail!("not a checkpoint topic: {topic}")
+ };
+ let note = notes::Simple::checkpoint(kind, bundle.references.clone(), message);
+ let parent = topic::default_reply_to(self.repo.target(), topic)?
+ .map(|id| self.repo.source().find_commit(id))
+ .transpose()?;
+
+ self.annotate(bundle, topic, parent, &note)
+ }
+
+ fn annotate_patch(
+ &mut self,
+ bundle: &mut bundle::Header,
+ cover: Option<String>,
+ re: Option<(Topic, Option<git2::Oid>)>,
+ ) -> cmd::Result<()> {
+ let cover = cover
+ .map(notes::Simple::new)
+ .map(Ok)
+ .unwrap_or_else(|| edit_cover_letter(self.repo.source()))?;
+ let (topic, parent) = match re {
+ Some((topic, reply_to)) => {
+ let parent = find_reply_to(self.repo, &topic, reply_to)?;
+ (topic, Some(parent))
+ },
+ None => {
+ // This is pretty arbitrary -- just use a random string instead?
+ let topic = {
+ let mut hasher = Sha256::new();
+ hasher.update(record::Heads::from(bundle as &bundle::Header));
+ serde_json::to_writer(&mut hasher, &cover)?;
+ hasher.update(self.submitter.signer.ident().keyid());
+ Topic::from(hasher.finalize())
+ };
+ let parent = topic::default_reply_to(self.repo.target(), &topic)?
+ .map(|id| self.repo.source().find_commit(id))
+ .transpose()?;
+
+ (topic, parent)
+ },
+ };
+
+ self.annotate(bundle, &topic, parent, &cover)
+ }
+
+ fn annotate_comment(
+ &mut self,
+ bundle: &mut bundle::Header,
+ topic: Topic,
+ message: Option<String>,
+ reply_to: Option<git2::Oid>,
+ ) -> cmd::Result<()> {
+ let parent = find_reply_to(self.repo, &topic, reply_to)?;
+ let edit = || -> cmd::Result<notes::Simple> {
+ let re = notes::Simple::from_commit(self.repo.target(), &parent)?;
+ edit_comment(self.repo.source(), Some(&re))
+ };
+ let comment = message
+ .map(notes::Simple::new)
+ .map(Ok)
+ .unwrap_or_else(edit)?;
+
+ self.annotate(bundle, &topic, Some(parent), &comment)
+ }
+
+ fn annotate(
+ &mut self,
+ bundle: &mut bundle::Header,
+ topic: &Topic,
+ parent: Option<git2::Commit>,
+ note: &notes::Simple,
+ ) -> cmd::Result<()> {
+ let repo = self.repo.source();
+ let topic_ref = topic.as_refname();
+ let tree = {
+ let mut tb = repo.treebuilder(None)?;
+ patches::to_tree(repo, &mut tb, note)?;
+ repo.find_tree(tb.write()?)?
+ };
+ let msg = match note.subject() {
+ Some(s) => format!("{}\n\n{}", s, topic.as_trailer()),
+ None => topic.as_trailer(),
+ };
+ let commit = git::commit_signed(
+ self.submitter.signer,
+ repo,
+ &msg,
+ &tree,
+ parent.as_ref().into_iter().collect::<Vec<_>>().as_slice(),
+ )?;
+
+ if let Some(commit) = parent {
+ bundle.add_prerequisite(&commit.id());
+ }
+ bundle.add_reference(topic_ref, &commit);
+
+ Ok(())
+ }
+}
+
+fn mergepoint(
+ repos: &Repo,
+ meta: &metadata::drop::Verified,
+ bundle: &mut bundle::Header,
+ force: bool,
+) -> git::Result<()> {
+ for branch in meta.roles.branches.keys() {
+ let sandboxed = match patches::TrackingBranch::try_from(branch) {
+ Ok(tracking) => tracking,
+ Err(e) => {
+ warn!("Skipping invalid branch {branch}: {e}");
+ continue;
+ },
+ };
+ let head = {
+ let local = repos.source().find_reference(branch)?;
+ let head = local.peel_to_commit()?.id();
+ if !force {
+ if let Some(upstream) = if_not_found_none(git2::Branch::wrap(local).upstream())? {
+ let upstream_head = upstream.get().peel_to_commit()?.id();
+ if head != upstream_head {
+ warn!(
+ "Upstream {} is not even with {branch}; you may want to push first",
+ String::from_utf8_lossy(upstream.name_bytes()?)
+ );
+ info!("Skipping {branch}");
+ continue;
+ }
+ }
+ }
+
+ head
+ };
+ match if_not_found_none(repos.target().find_reference(&sandboxed))? {
+ Some(base) => {
+ let base = base.peel_to_commit()?.id();
+ if base == head {
+ info!("Skipping empty checkpoint");
+ } else if if_not_found_none(repos.source().merge_base(base, head))?.is_some() {
+ info!("Adding thin checkpoint for branch {branch}: {base}..{head}");
+ bundle.add_prerequisite(&base);
+ bundle.add_reference(branch.clone(), &head);
+ } else {
+ warn!(
+ "{branch} diverges from drop state: no merge base between {base}..{head}"
+ );
+ }
+ },
+
+ None => {
+ info!("Adding full checkpoint for branch {branch}: {head}");
+ bundle.add_reference(branch.clone(), &head);
+ },
+ }
+ }
+
+ Ok(())
+}
+
+fn snapshot(repo: &Repo, bundle: &mut bundle::Header, incremental: bool) -> cmd::Result<()> {
+ for record in dropped::records(repo.target(), REF_IT_PATCHES) {
+ let record = record?;
+ let bundle_hash = record.bundle_hash();
+ if record.is_encrypted() {
+ warn!("Skipping encrypted patch bundle {bundle_hash}",);
+ continue;
+ }
+
+ if record.topic == *TOPIC_SNAPSHOTS {
+ if !incremental {
+ debug!("Full snapshot: skipping previous snapshot {bundle_hash}");
+ continue;
+ } else {
+ info!("Incremental snapshot: found previous snapshot {bundle_hash}");
+ for oid in record.meta.bundle.references.values().copied() {
+ info!("Adding prerequisite {oid} from {bundle_hash}");
+ bundle.add_prerequisite(oid);
+ }
+ break;
+ }
+ }
+
+ info!("Including {bundle_hash} in snapshot");
+ for (name, oid) in &record.meta.bundle.references {
+ info!("Adding {oid} {name}");
+ let name = patches::unbundled_ref(REF_IT_BUNDLES, &record, name)?;
+ bundle.add_reference(name, *oid);
+ }
+ }
+
+ Ok(())
+}
+
+fn find_reply_to<'a>(
+ repo: &'a Repo,
+ topic: &Topic,
+ reply_to: Option<git2::Oid>,
+) -> cmd::Result<git2::Commit<'a>> {
+ let tip = if_not_found_none(repo.target().refname_to_id(&topic.as_refname()))?
+ .ok_or_else(|| anyhow!("topic {topic} does not exist"))?;
+ let id = match reply_to {
+ Some(id) => {
+ ensure!(
+ repo.target().graph_descendant_of(tip, id)?,
+ "{id} not found in topic {topic}, cannot reply"
+ );
+ id
+ },
+ None => topic::default_reply_to(repo.target(), topic)?.expect("impossible: empty topic"),
+ };
+
+ Ok(repo.source().find_commit(id)?)
+}
+
+struct Identity {
+ hash: ContentHash,
+ verified: identity::Verified,
+ update: Option<Range>,
+}
+
+impl Identity {
+ pub fn find(
+ repo: &git2::Repository,
+ ids: &git2::Tree,
+ id_path: &[git2::Repository],
+ refname: Refname,
+ ) -> cmd::Result<Self> {
+ let find_parent = metadata::git::find_parent(repo);
+
+ struct Meta {
+ hash: ContentHash,
+ id: identity::Verified,
+ }
+
+ impl Meta {
+ fn identity(&self) -> &metadata::Identity {
+ self.id.identity()
+ }
+ }
+
+ let (ours_in, ours) =
+ metadata::Identity::from_search_path(id_path, &refname).and_then(|data| {
+ let signer = data.meta.signed.verified(&find_parent)?;
+ Ok((
+ data.repo,
+ Meta {
+ hash: data.meta.hash,
+ id: signer,
+ },
+ ))
+ })?;
+
+ let tree_path = PathBuf::from(ours.id.id().to_string()).join(META_FILE_ID);
+ let newer = match if_not_found_none(ids.get_path(&tree_path))? {
+ None => {
+ let start = ours_in.refname_to_id(&refname)?;
+ let range = Range {
+ refname,
+ start,
+ end: None,
+ };
+ Self {
+ hash: ours.hash,
+ verified: ours.id,
+ update: Some(range),
+ }
+ },
+ Some(in_tree) if ours.hash == in_tree.id() => Self {
+ hash: ours.hash,
+ verified: ours.id,
+ update: None,
+ },
+ Some(in_tree) => {
+ let theirs = metadata::Identity::from_blob(&repo.find_blob(in_tree.id())?)
+ .and_then(|GitMeta { hash, signed }| {
+ let signer = signed.verified(&find_parent)?;
+ Ok(Meta { hash, id: signer })
+ })?;
+
+ if ours.identity().has_ancestor(&theirs.hash, &find_parent)? {
+ let range = Range::compute(ours_in, refname, theirs.hash.as_oid())?;
+ Self {
+ hash: ours.hash,
+ verified: ours.id,
+ update: range,
+ }
+ } else if theirs.identity().has_ancestor(&ours.hash, &find_parent)? {
+ Self {
+ hash: theirs.hash,
+ verified: theirs.id,
+ update: None,
+ }
+ } else {
+ bail!(
+ "provided identity at {} diverges from in-tree at {}",
+ ours.hash,
+ theirs.hash,
+ )
+ }
+ },
+ };
+
+ Ok(newer)
+ }
+
+ pub fn id(&self) -> &IdentityId {
+ self.verified.id()
+ }
+
+ pub fn hash(&self) -> &ContentHash {
+ &self.hash
+ }
+
+ pub fn contains(&self, key: &KeyId) -> bool {
+ self.verified.identity().keys.contains_key(key)
+ }
+
+ pub fn update(&self, bundle: &mut bundle::Header) {
+ if let Some(range) = &self.update {
+ range.add_to_bundle(bundle);
+ }
+ }
+}
+
+struct Range {
+ refname: Refname,
+ start: git2::Oid,
+ end: Option<git2::Oid>,
+}
+
+impl Range {
+ fn compute(
+ repo: &git2::Repository,
+ refname: Refname,
+ known: git2::Oid,
+ ) -> cmd::Result<Option<Self>> {
+ let start = repo.refname_to_id(&refname)?;
+
+ let mut walk = repo.revwalk()?;
+ walk.push(start)?;
+ for oid in walk {
+ let oid = oid?;
+ let blob_id = repo
+ .find_commit(oid)?
+ .tree()?
+ .get_name(META_FILE_ID)
+ .ok_or_else(|| anyhow!("corrupt identity: missing {META_FILE_ID}"))?
+ .id();
+
+ if blob_id == known {
+ return Ok(if oid == start {
+ None
+ } else {
+ Some(Self {
+ refname,
+ start,
+ end: Some(oid),
+ })
+ });
+ }
+ }
+
+ Ok(Some(Self {
+ refname,
+ start,
+ end: None,
+ }))
+ }
+
+ fn add_to_bundle(&self, header: &mut bundle::Header) {
+ header.add_reference(self.refname.clone(), &self.start);
+ if let Some(end) = self.end {
+ header.add_prerequisite(&end);
+ }
+ }
+}
diff --git a/src/cmd/topic.rs b/src/cmd/topic.rs
new file mode 100644
index 0000000..fe4e2df
--- /dev/null
+++ b/src/cmd/topic.rs
@@ -0,0 +1,58 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::path::PathBuf;
+
+use crate::cmd;
+
+pub mod comment;
+
+mod ls;
+pub use ls::{
+ ls,
+ Ls,
+};
+
+mod show;
+pub use show::{
+ show,
+ Show,
+};
+
+mod unbundle;
+pub use unbundle::{
+ unbundle,
+ Unbundle,
+};
+
+#[derive(Debug, clap::Subcommand)]
+#[allow(clippy::large_enum_variant)]
+pub enum Cmd {
+ /// List the recorded topics
+ Ls(Ls),
+ /// Show a topic
+ Show(Show),
+ /// Comment on a topic
+ #[clap(subcommand)]
+ Comment(comment::Cmd),
+ /// Unbundle a topic
+ Unbundle(Unbundle),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Ls(args) => ls(args).map(cmd::Output::iter),
+ Self::Show(args) => show(args).map(cmd::Output::iter),
+ Self::Comment(cmd) => cmd.run(),
+ Self::Unbundle(args) => unbundle(args).map(cmd::Output::val),
+ }
+ }
+}
+
+#[derive(Debug, clap::Args)]
+struct Common {
+ /// Path to the drop repository
+ #[clap(from_global)]
+ git_dir: PathBuf,
+}
diff --git a/src/cmd/topic/comment.rs b/src/cmd/topic/comment.rs
new file mode 100644
index 0000000..121dabb
--- /dev/null
+++ b/src/cmd/topic/comment.rs
@@ -0,0 +1,68 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::{
+ cmd::{
+ self,
+ patch,
+ },
+ patches,
+};
+
+#[derive(Debug, clap::Subcommand)]
+pub enum Cmd {
+ /// Record the comment with a local drop history
+ Record(Record),
+ /// Submit the comment to a remote drop
+ Submit(Submit),
+}
+
+impl Cmd {
+ pub fn run(self) -> cmd::Result<cmd::Output> {
+ match self {
+ Self::Record(args) => record(args),
+ Self::Submit(args) => submit(args),
+ }
+ .map(cmd::IntoOutput::into_output)
+ }
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Record {
+ #[clap(flatten)]
+ common: patch::Common,
+ #[clap(flatten)]
+ comment: patch::Comment,
+}
+
+#[derive(Debug, clap::Args)]
+pub struct Submit {
+ #[clap(flatten)]
+ common: patch::Common,
+ #[clap(flatten)]
+ comment: patch::Comment,
+ #[clap(flatten)]
+ remote: patch::Remote,
+}
+
+pub fn record(Record { common, comment }: Record) -> cmd::Result<patches::Record> {
+ patch::create(patch::Kind::Comment {
+ common,
+ remote: None,
+ comment,
+ })
+}
+
+pub fn submit(
+ Submit {
+ common,
+ comment,
+ remote,
+ }: Submit,
+) -> cmd::Result<patches::Record> {
+ patch::create(patch::Kind::Comment {
+ common,
+ remote: Some(remote),
+ comment,
+ })
+}
diff --git a/src/cmd/topic/ls.rs b/src/cmd/topic/ls.rs
new file mode 100644
index 0000000..430cc6e
--- /dev/null
+++ b/src/cmd/topic/ls.rs
@@ -0,0 +1,32 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::{
+ git,
+ patches::{
+ self,
+ Topic,
+ },
+};
+
+use super::Common;
+use crate::cmd;
+
+#[derive(Debug, clap::Args)]
+pub struct Ls {
+ #[clap(flatten)]
+ common: Common,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ topic: Topic,
+ subject: String,
+}
+
+pub fn ls(args: Ls) -> cmd::Result<Vec<cmd::Result<Output>>> {
+ let repo = git::repo::open(&args.common.git_dir)?;
+ Ok(patches::iter::unbundled::topics_with_subject(&repo)
+ .map(|i| i.map(|(topic, subject)| Output { topic, subject }))
+ .collect())
+}
diff --git a/src/cmd/topic/show.rs b/src/cmd/topic/show.rs
new file mode 100644
index 0000000..1d19720
--- /dev/null
+++ b/src/cmd/topic/show.rs
@@ -0,0 +1,34 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use super::Common;
+use crate::{
+ cmd,
+ git,
+ patches::{
+ self,
+ iter::Note,
+ Topic,
+ },
+};
+
+#[derive(Debug, clap::Args)]
+pub struct Show {
+ #[clap(flatten)]
+ common: Common,
+ /// Traverse the topic in reverse order, ie. oldest first
+ #[clap(long, value_parser)]
+ reverse: bool,
+ #[clap(value_parser)]
+ topic: Topic,
+}
+
+pub fn show(args: Show) -> cmd::Result<Vec<cmd::Result<Note>>> {
+ let repo = git::repo::open(&args.common.git_dir)?;
+ let iter = patches::iter::topic(&repo, &args.topic);
+ if args.reverse {
+ Ok(iter.rev().collect())
+ } else {
+ Ok(iter.collect())
+ }
+}
diff --git a/src/cmd/topic/unbundle.rs b/src/cmd/topic/unbundle.rs
new file mode 100644
index 0000000..3aab54b
--- /dev/null
+++ b/src/cmd/topic/unbundle.rs
@@ -0,0 +1,174 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::{
+ BTreeMap,
+ BTreeSet,
+ },
+ path::PathBuf,
+};
+
+use anyhow::anyhow;
+use clap::ValueHint;
+
+use super::Common;
+use crate::{
+ cmd::{
+ self,
+ ui::{
+ debug,
+ info,
+ warn,
+ },
+ Aborted,
+ },
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ Refname,
+ },
+ metadata::{
+ self,
+ git::FromGit,
+ },
+ patches::{
+ self,
+ iter::dropped,
+ Bundle,
+ Record,
+ Topic,
+ REF_IT_BUNDLES,
+ REF_IT_PATCHES,
+ TOPIC_MERGES,
+ TOPIC_SNAPSHOTS,
+ },
+ paths,
+};
+
+// TODO:
+//
+// - don't require patch bundle to be present on-disk when snapshots would do
+
+#[derive(Debug, clap::Args)]
+pub struct Unbundle {
+ #[clap(flatten)]
+ common: Common,
+ /// The directory where to write the bundle to
+ ///
+ /// Unless this is an absolute path, it is treated as relative to $GIT_DIR.
+ #[clap(
+ long,
+ value_parser,
+ value_name = "DIR",
+ default_value_os_t = paths::bundles().to_owned(),
+ value_hint = ValueHint::DirPath,
+ )]
+ bundle_dir: PathBuf,
+ /// The topic to unbundle
+ #[clap(value_parser)]
+ topic: Topic,
+ /// The drop history to find the topic in
+ #[clap(value_parser)]
+ drop: Option<String>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Output {
+ updated: BTreeMap<Refname, git::serde::oid::Oid>,
+}
+
+pub fn unbundle(args: Unbundle) -> cmd::Result<Output> {
+ let repo = git::repo::open(&args.common.git_dir)?;
+ let bundle_dir = if args.bundle_dir.is_relative() {
+ repo.path().join(args.bundle_dir)
+ } else {
+ args.bundle_dir
+ };
+ let drop = match args.drop {
+ Some(rev) => if_not_found_none(repo.resolve_reference_from_short_name(&rev))?
+ .ok_or_else(|| anyhow!("no ref matching {rev} found"))?
+ .name()
+ .ok_or_else(|| anyhow!("invalid drop"))?
+ .to_owned(),
+ None => REF_IT_PATCHES.to_owned(),
+ };
+
+ let filter = [&args.topic, &TOPIC_MERGES, &TOPIC_SNAPSHOTS];
+ let mut on_topic: Vec<Record> = Vec::new();
+ let mut checkpoints: Vec<Record> = Vec::new();
+ for row in dropped::topics(&repo, &drop) {
+ let (t, id) = row?;
+
+ if filter.into_iter().any(|f| f == &t) {
+ let commit = repo.find_commit(id)?;
+ let record = Record::from_commit(&repo, &commit)?;
+ if t == args.topic {
+ on_topic.push(record);
+ continue;
+ }
+
+ // Skip checkpoint which came after the most recent record on the topic
+ if !on_topic.is_empty() {
+ checkpoints.push(record);
+ }
+ }
+ }
+
+ let odb = repo.odb()?;
+
+ info!("Indexing checkpoints...");
+ for rec in checkpoints.into_iter().rev() {
+ Bundle::from_stored(&bundle_dir, rec.bundle_info().as_expect())?
+ .packdata()?
+ .index(&odb)?
+ }
+
+ let mut missing = BTreeSet::new();
+ for oid in on_topic
+ .iter()
+ .flat_map(|rec| &rec.bundle_info().prerequisites)
+ {
+ let oid = git2::Oid::try_from(oid)?;
+ if !odb.exists(oid) {
+ missing.insert(oid);
+ }
+ }
+
+ if !missing.is_empty() {
+ warn!("Unable to satisfy all prerequisites");
+ info!("The following prerequisite commits are missing:\n");
+ for oid in missing {
+ info!("{oid}");
+ }
+ info!("\nYou may try to unbundle the entire drop history");
+ cmd::abort!();
+ }
+
+ info!("Unbundling topic records...");
+ let mut tx = refs::Transaction::new(&repo)?;
+ let topic_ref = tx.lock_ref(args.topic.as_refname())?;
+ let mut up = BTreeMap::new();
+ for rec in on_topic.into_iter().rev() {
+ let hash = rec.bundle_hash();
+ let bundle = Bundle::from_stored(&bundle_dir, rec.bundle_info().as_expect())?;
+ if bundle.is_encrypted() {
+ warn!("Skipping encrypted bundle {hash}");
+ continue;
+ }
+ bundle.packdata()?.index(&odb)?;
+ debug!("{hash}: unbundle");
+ let updated = patches::unbundle(&odb, &mut tx, REF_IT_BUNDLES, &rec)?;
+ for (name, oid) in updated {
+ up.insert(name, oid.into());
+ }
+ debug!("{hash}: merge notes");
+ let submitter = metadata::Identity::from_content_hash(&repo, &rec.meta.signature.signer)?
+ .verified(metadata::git::find_parent(&repo))?;
+ patches::merge_notes(&repo, &submitter, &topic_ref, &rec)?;
+ }
+ tx.commit()?;
+
+ Ok(Output { updated: up })
+}
diff --git a/src/cmd/ui.rs b/src/cmd/ui.rs
new file mode 100644
index 0000000..c1ad214
--- /dev/null
+++ b/src/cmd/ui.rs
@@ -0,0 +1,131 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ borrow::Cow,
+ env,
+ ffi::OsStr,
+ io,
+ process::{
+ self,
+ Command,
+ Stdio,
+ },
+};
+
+use anyhow::ensure;
+use console::Term;
+use zeroize::Zeroizing;
+
+use crate::{
+ cmd::{
+ self,
+ Aborted,
+ },
+ patches::notes,
+};
+
+mod editor;
+mod output;
+pub use output::{
+ debug,
+ error,
+ info,
+ warn,
+ Output,
+};
+
+pub fn edit_commit_message(
+ repo: &git2::Repository,
+ branch: &str,
+ old: &git2::Tree,
+ new: &git2::Tree,
+) -> cmd::Result<String> {
+ let diff = repo.diff_tree_to_tree(
+ Some(old),
+ Some(new),
+ Some(
+ git2::DiffOptions::new()
+ .patience(true)
+ .minimal(true)
+ .context_lines(5),
+ ),
+ )?;
+ abort_if_empty(
+ "commit message",
+ editor::Commit::new(repo.path())?.edit(branch, diff),
+ )
+}
+
+pub fn edit_cover_letter(repo: &git2::Repository) -> cmd::Result<notes::Simple> {
+ abort_if_empty(
+ "cover letter",
+ editor::CoverLetter::new(repo.path())?.edit(),
+ )
+}
+
+pub fn edit_comment(
+ repo: &git2::Repository,
+ re: Option<&notes::Simple>,
+) -> cmd::Result<notes::Simple> {
+ abort_if_empty("comment", editor::Comment::new(repo.path())?.edit(re))
+}
+
+pub fn edit_metadata<T>(template: T) -> cmd::Result<T>
+where
+ T: serde::Serialize + serde::de::DeserializeOwned,
+{
+ abort_if_empty("metadata", editor::Metadata::new()?.edit(template))
+}
+
+fn abort_if_empty<T>(ctx: &str, edit: io::Result<Option<T>>) -> cmd::Result<T> {
+ edit?.map(Ok).unwrap_or_else(|| {
+ info!("Aborting due to empty {ctx}");
+ cmd::abort!()
+ })
+}
+
+pub fn askpass(prompt: &str) -> cmd::Result<Zeroizing<Vec<u8>>> {
+ const DEFAULT_ASKPASS: &str = "ssh-askpass";
+
+ fn ssh_askpass() -> Cow<'static, OsStr> {
+ env::var_os("SSH_ASKPASS")
+ .map(Into::into)
+ .unwrap_or_else(|| OsStr::new(DEFAULT_ASKPASS).into())
+ }
+
+ let ssh = env::var_os("SSH_ASKPASS_REQUIRE").and_then(|require| {
+ if require == "force" {
+ Some(ssh_askpass())
+ } else if require == "prefer" {
+ env::var_os("DISPLAY").map(|_| ssh_askpass())
+ } else {
+ None
+ }
+ });
+
+ match ssh {
+ Some(cmd) => {
+ let process::Output { status, stdout, .. } = Command::new(&cmd)
+ .arg(prompt)
+ .stderr(Stdio::inherit())
+ .output()?;
+ ensure!(
+ status.success(),
+ "{} failed with {:?}",
+ cmd.to_string_lossy(),
+ status.code()
+ );
+ Ok(Zeroizing::new(stdout))
+ },
+ None => {
+ let tty = Term::stderr();
+ if tty.is_term() {
+ tty.write_line(prompt)?;
+ }
+ tty.read_secure_line()
+ .map(|s| Zeroizing::new(s.into_bytes()))
+ .map_err(Into::into)
+ },
+ }
+}
diff --git a/src/cmd/ui/editor.rs b/src/cmd/ui/editor.rs
new file mode 100644
index 0000000..a2a7a5e
--- /dev/null
+++ b/src/cmd/ui/editor.rs
@@ -0,0 +1,228 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ env,
+ ffi::OsString,
+ io::{
+ self,
+ BufRead as _,
+ Write as _,
+ },
+ path::{
+ Path,
+ PathBuf,
+ },
+ process::Command,
+};
+
+use tempfile::TempPath;
+
+use crate::{
+ fs::LockedFile,
+ patches::notes,
+};
+
+const SCISSORS: &str = "# ------------------------ >8 ------------------------";
+
+pub struct Commit(Editmsg);
+
+impl Commit {
+ pub fn new<P: AsRef<Path>>(git_dir: P) -> io::Result<Self> {
+ Editmsg::new(git_dir.as_ref().join("COMMIT_EDITMSG")).map(Self)
+ }
+
+ pub fn edit(self, branch: &str, diff: git2::Diff) -> io::Result<Option<String>> {
+ let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch);
+ self.0.edit(|buf| {
+ write!(
+ buf,
+ "
+# Please enter the commit message for your changes. Lines starting
+# with '#' will be ignored, and an empty message aborts the commit.
+#
+# On branch {branch}
+#
+{SCISSORS}
+# Do not modify or remove the line above.
+# Everything below it will be ignored.
+#
+# Changes to be committed:
+"
+ )?;
+ diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
+ use git2::DiffLineType::{
+ Addition,
+ Context,
+ Deletion,
+ };
+ let ok = if matches!(line.origin_value(), Context | Addition | Deletion) {
+ write!(buf, "{}", line.origin()).is_ok()
+ } else {
+ true
+ };
+ ok && buf.write_all(line.content()).is_ok()
+ })
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
+ Ok(())
+ })
+ }
+}
+
+pub struct CoverLetter(Editmsg);
+
+impl CoverLetter {
+ pub fn new<P: AsRef<Path>>(git_dir: P) -> io::Result<Self> {
+ Editmsg::new(git_dir.as_ref().join("NOTES_EDITMSG")).map(Self)
+ }
+
+ // TODO: render patch series a la git log
+ pub fn edit(self) -> io::Result<Option<notes::Simple>> {
+ let txt = self.0.edit(|buf| {
+ writeln!(
+ buf,
+ "
+# Please describe your patch as you would in a cover letter or PR.
+# Lines starting with '#' will be ignored, and an empty message
+# aborts the patch creation.
+#
+{SCISSORS}
+# Do not modify or remove the line above.
+# Everything below it will be ignored.
+#
+# Changes to be committed:
+
+TODO (sorry)
+"
+ )?;
+
+ Ok(())
+ })?;
+
+ Ok(txt.map(notes::Simple::new))
+ }
+}
+
+pub struct Comment(Editmsg);
+
+impl Comment {
+ pub fn new<P: AsRef<Path>>(git_dir: P) -> io::Result<Self> {
+ Editmsg::new(git_dir.as_ref().join("NOTES_EDITMSG")).map(Self)
+ }
+
+ pub fn edit(self, re: Option<&notes::Simple>) -> io::Result<Option<notes::Simple>> {
+ let txt = self.0.edit(|buf| {
+ write!(
+ buf,
+ "
+# Enter your comment above. Lines starting with '#' will be ignored,
+# and an empty message aborts the comment creation.
+"
+ )?;
+
+ if let Some(prev) = re {
+ write!(
+ buf,
+ "#
+{SCISSORS}
+# Do not modify or remove the line above.
+# Everything below it will be ignored.
+#
+# Replying to:
+"
+ )?;
+
+ serde_json::to_writer_pretty(buf, prev)?;
+ }
+
+ Ok(())
+ })?;
+
+ Ok(txt.map(notes::Simple::new))
+ }
+}
+
+pub struct Metadata {
+ _tmp: TempPath,
+ msg: Editmsg,
+}
+
+impl Metadata {
+ pub fn new() -> io::Result<Self> {
+ let _tmp = tempfile::Builder::new()
+ .suffix(".json")
+ .tempfile()?
+ .into_temp_path();
+ let msg = Editmsg::new(&_tmp)?;
+
+ Ok(Self { _tmp, msg })
+ }
+
+ // TODO: explainers, edit errors
+ pub fn edit<T>(self, template: T) -> io::Result<Option<T>>
+ where
+ T: serde::Serialize + serde::de::DeserializeOwned,
+ {
+ let txt = self.msg.edit(|buf| {
+ serde_json::to_writer_pretty(buf, &template)?;
+
+ Ok(())
+ })?;
+
+ Ok(txt.as_deref().map(serde_json::from_str).transpose()?)
+ }
+}
+
+struct Editmsg {
+ file: LockedFile,
+}
+
+impl Editmsg {
+ fn new<P: Into<PathBuf>>(path: P) -> io::Result<Self> {
+ LockedFile::in_place(path, true, 0o644).map(|file| Self { file })
+ }
+
+ fn edit<F>(mut self, pre_fill: F) -> io::Result<Option<String>>
+ where
+ F: FnOnce(&mut LockedFile) -> io::Result<()>,
+ {
+ pre_fill(&mut self.file)?;
+ Command::new(editor())
+ .arg(self.file.edit_path())
+ .spawn()?
+ .wait()?;
+ self.file.reopen()?;
+ let mut msg = String::new();
+ for line in io::BufReader::new(self.file).lines() {
+ let line = line?;
+ if line == SCISSORS {
+ break;
+ }
+ if line.starts_with('#') {
+ continue;
+ }
+
+ msg.push_str(&line);
+ msg.push('\n');
+ }
+ let len = msg.trim_end().len();
+ msg.truncate(len);
+
+ Ok(if msg.is_empty() { None } else { Some(msg) })
+ }
+}
+
+fn editor() -> OsString {
+ #[cfg(windows)]
+ const DEFAULT_EDITOR: &str = "notepad.exe";
+ #[cfg(not(windows))]
+ const DEFAULT_EDITOR: &str = "vi";
+
+ if let Some(exe) = env::var_os("VISUAL") {
+ return exe;
+ }
+ if let Some(exe) = env::var_os("EDITOR") {
+ return exe;
+ }
+ DEFAULT_EDITOR.into()
+}
diff --git a/src/cmd/ui/output.rs b/src/cmd/ui/output.rs
new file mode 100644
index 0000000..f1ad598
--- /dev/null
+++ b/src/cmd/ui/output.rs
@@ -0,0 +1,44 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+pub use log::{
+ debug,
+ error,
+ info,
+ warn,
+};
+
+pub struct Output;
+
+impl log::Log for Output {
+ fn enabled(&self, metadata: &log::Metadata) -> bool {
+ metadata.level() <= log::max_level()
+ }
+
+ fn log(&self, record: &log::Record) {
+ let meta = record.metadata();
+ if !self.enabled(meta) {
+ return;
+ }
+ let level = meta.level();
+ let style = {
+ let s = console::Style::new().for_stderr();
+ if level < log::Level::Info
+ && console::user_attended_stderr()
+ && console::colors_enabled_stderr()
+ {
+ match level {
+ log::Level::Error => s.red(),
+ log::Level::Warn => s.yellow(),
+ log::Level::Info | log::Level::Debug | log::Level::Trace => unreachable!(),
+ }
+ } else {
+ s
+ }
+ };
+
+ eprintln!("{}", style.apply_to(record.args()));
+ }
+
+ fn flush(&self) {}
+}
diff --git a/src/cmd/util.rs b/src/cmd/util.rs
new file mode 100644
index 0000000..27654d8
--- /dev/null
+++ b/src/cmd/util.rs
@@ -0,0 +1,4 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+pub mod args;
diff --git a/src/cmd/util/args.rs b/src/cmd/util/args.rs
new file mode 100644
index 0000000..e372c82
--- /dev/null
+++ b/src/cmd/util/args.rs
@@ -0,0 +1,139 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::{
+ fmt,
+ slice,
+ str::FromStr,
+};
+use std::{
+ borrow::Borrow,
+ convert::Infallible,
+ env,
+ path::PathBuf,
+ vec,
+};
+
+pub use crate::git::Refname;
+use crate::{
+ cfg::paths,
+ git,
+};
+
+/// Search path akin to the `PATH` environment variable.
+#[derive(Clone, Debug)]
+pub struct SearchPath(Vec<PathBuf>);
+
+impl SearchPath {
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+}
+
+impl fmt::Display for SearchPath {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ std::env::join_paths(&self.0)
+ .unwrap()
+ .to_string_lossy()
+ .fmt(f)
+ }
+}
+
+impl FromStr for SearchPath {
+ type Err = Infallible;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Ok(Self(env::split_paths(s).collect()))
+ }
+}
+
+impl IntoIterator for SearchPath {
+ type Item = PathBuf;
+ type IntoIter = vec::IntoIter<PathBuf>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a SearchPath {
+ type Item = &'a PathBuf;
+ type IntoIter = slice::Iter<'a, PathBuf>;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.iter()
+ }
+}
+
+/// A [`SearchPath`] with a [`Default`] appropriate for `it` identity
+/// repositories.
+#[derive(Clone, Debug)]
+pub struct IdSearchPath(SearchPath);
+
+impl IdSearchPath {
+ pub fn is_empty(&self) -> bool {
+ self.0.is_empty()
+ }
+
+ pub fn len(&self) -> usize {
+ self.0.len()
+ }
+
+ /// Attempt to open each path element as a git repository
+ ///
+ /// The repositories will be opened as bare, even if they aren't. No error
+ /// is returned if a repo could not be opened (e.g. because it is not a git
+ /// repository).
+ pub fn open_git(&self) -> Vec<git2::Repository> {
+ let mut rs = Vec::with_capacity(self.len());
+ for path in self {
+ if let Ok(repo) = git::repo::open_bare(path) {
+ rs.push(repo);
+ }
+ }
+
+ rs
+ }
+}
+
+impl Default for IdSearchPath {
+ fn default() -> Self {
+ Self(SearchPath(vec![paths::ids()]))
+ }
+}
+
+impl fmt::Display for IdSearchPath {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl FromStr for IdSearchPath {
+ type Err = <SearchPath as FromStr>::Err;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ s.parse().map(Self)
+ }
+}
+
+impl IntoIterator for IdSearchPath {
+ type Item = <SearchPath as IntoIterator>::Item;
+ type IntoIter = <SearchPath as IntoIterator>::IntoIter;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.into_iter()
+ }
+}
+
+impl<'a> IntoIterator for &'a IdSearchPath {
+ type Item = <&'a SearchPath as IntoIterator>::Item;
+ type IntoIter = <&'a SearchPath as IntoIterator>::IntoIter;
+
+ fn into_iter(self) -> Self::IntoIter {
+ self.0.borrow().into_iter()
+ }
+}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..e202dfa
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,12 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+pub type Error = anyhow::Error;
+pub type Result<T> = anyhow::Result<T>;
+
+#[derive(Debug, thiserror::Error)]
+#[error("{what} not found in {whence}")]
+pub struct NotFound<T, U> {
+ pub what: T,
+ pub whence: U,
+}
diff --git a/src/fs.rs b/src/fs.rs
new file mode 100644
index 0000000..436ec83
--- /dev/null
+++ b/src/fs.rs
@@ -0,0 +1,192 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ fs::{
+ remove_file,
+ rename,
+ File,
+ },
+ io::{
+ self,
+ Read,
+ Seek,
+ Write,
+ },
+ path::{
+ Path,
+ PathBuf,
+ },
+};
+
+/// A [`File`] which is protected by a git-style lock file
+///
+/// When a [`LockedFile`] is created, a lock file named after its path with
+/// suffix ".lock" is created with `O_EXCL`. That is, if the lock file already
+/// exists, the operation will fail.
+///
+/// Then, either the lock file (when using [`LockedFile::atomic`]) or the base
+/// file (when using [`LockedFile::in_place`] is opened for writing.
+/// [`LockedFile`] implements [`Write`], [`Read`], and [`Seek`].
+///
+/// When a [`LockedFile`] is dropped, the lock file is unlinked. **NOTE** that
+/// this may leave the lock file in place if the process exits forcefully.
+///
+/// When using [`LockedFile::atomic`], the modified lock file is renamed to the
+/// base file atomically. For this to happen, [`LockedFile::persist`] must be
+/// called explicitly.
+pub struct LockedFile {
+ /// Path to the lock file
+ lock: PathBuf,
+ /// Path to the file being edited
+ path: PathBuf,
+ /// File being edited
+ edit: File,
+ /// Commit mode
+ mode: Commit,
+}
+
+enum Commit {
+ Atomic,
+ InPlace,
+}
+
+impl Drop for LockedFile {
+ fn drop(&mut self) {
+ remove_file(&self.lock).ok();
+ }
+}
+
+impl LockedFile {
+ pub const DEFAULT_PERMISSIONS: u32 = 0o644;
+
+ pub fn atomic<P, M>(path: P, truncate: bool, mode: M) -> io::Result<Self>
+ where
+ P: Into<PathBuf>,
+ M: Into<Option<u32>>,
+ {
+ let path = path.into();
+ let perm = mode.into().unwrap_or(Self::DEFAULT_PERMISSIONS);
+ let lock = path.with_extension("lock");
+ let mut edit = File::options()
+ .read(true)
+ .write(true)
+ .create_new(true)
+ .permissions(perm)
+ .open(&lock)?;
+ if !truncate && path.exists() {
+ std::fs::copy(&path, &lock)?;
+ edit = File::options().read(true).append(true).open(&lock)?;
+ }
+ let mode = Commit::Atomic;
+
+ Ok(Self {
+ lock,
+ path,
+ edit,
+ mode,
+ })
+ }
+
+ pub fn in_place<P, M>(path: P, truncate: bool, mode: M) -> io::Result<Self>
+ where
+ P: Into<PathBuf>,
+ M: Into<Option<u32>>,
+ {
+ let path = path.into();
+ let perm = mode.into().unwrap_or(Self::DEFAULT_PERMISSIONS);
+ let lock = path.with_extension("lock");
+ let _ = File::options()
+ .read(true)
+ .write(true)
+ .create_new(true)
+ .permissions(perm)
+ .open(&lock)?;
+ let edit = File::options()
+ .read(true)
+ .write(true)
+ .truncate(truncate)
+ .create(true)
+ .permissions(perm)
+ .open(&path)?;
+ let mode = Commit::InPlace;
+
+ Ok(Self {
+ lock,
+ path,
+ edit,
+ mode,
+ })
+ }
+
+ /// Reopen the file handle
+ ///
+ /// This is sometimes necessary, eg. when launching an editor to let the
+ /// user modify the file, in which case the file descriptor of the
+ /// handle is invalidated.
+ pub fn reopen(&mut self) -> io::Result<()> {
+ self.edit = File::options()
+ .read(true)
+ .write(true)
+ .open(self.edit_path())?;
+ Ok(())
+ }
+
+ pub fn edit_path(&self) -> &Path {
+ match self.mode {
+ Commit::Atomic => &self.lock,
+ Commit::InPlace => &self.path,
+ }
+ }
+
+ #[allow(unused)]
+ pub fn target_path(&self) -> &Path {
+ &self.path
+ }
+
+ pub fn persist(self) -> io::Result<()> {
+ match self.mode {
+ Commit::Atomic => rename(&self.lock, &self.path),
+ Commit::InPlace => remove_file(&self.lock),
+ }
+ }
+}
+
+impl Read for LockedFile {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+ self.edit.read(buf)
+ }
+}
+
+impl Write for LockedFile {
+ fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+ self.edit.write(buf)
+ }
+
+ fn flush(&mut self) -> io::Result<()> {
+ self.edit.flush()
+ }
+}
+
+impl Seek for LockedFile {
+ fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
+ self.edit.seek(pos)
+ }
+}
+
+pub(crate) trait PermissionsExt {
+ fn permissions(&mut self, mode: u32) -> &mut Self;
+}
+
+impl PermissionsExt for std::fs::OpenOptions {
+ #[cfg(unix)]
+ fn permissions(&mut self, mode: u32) -> &mut Self {
+ use std::os::unix::fs::OpenOptionsExt as _;
+ self.mode(mode)
+ }
+
+ #[cfg(not(unix))]
+ fn permissions(&mut self, mode: u32) -> &mut Self {
+ self
+ }
+}
diff --git a/src/git.rs b/src/git.rs
new file mode 100644
index 0000000..f837711
--- /dev/null
+++ b/src/git.rs
@@ -0,0 +1,111 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::process::{
+ self,
+ Command,
+};
+
+use anyhow::{
+ anyhow,
+ ensure,
+ Context,
+};
+use once_cell::sync::Lazy;
+use sha2::{
+ Digest,
+ Sha256,
+};
+
+mod commit;
+pub use commit::{
+ commit_signed,
+ verify_commit_signature,
+};
+
+pub mod config;
+
+pub mod refs;
+pub use refs::{
+ ReferenceNames,
+ Refname,
+};
+pub mod repo;
+pub use repo::add_alternates;
+pub mod serde;
+
+pub static EMPTY_TREE: Lazy<git2::Oid> =
+ Lazy::new(|| git2::Oid::from_str("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap());
+
+pub type Result<T> = core::result::Result<T, git2::Error>;
+
+pub fn empty_tree(repo: &git2::Repository) -> Result<git2::Tree> {
+ repo.find_tree(*EMPTY_TREE)
+}
+
+pub fn if_not_found_none<T>(r: Result<T>) -> Result<Option<T>> {
+ if_not_found_then(r.map(Some), || Ok(None))
+}
+
+pub fn if_not_found_then<F, T>(r: Result<T>, f: F) -> Result<T>
+where
+ F: FnOnce() -> Result<T>,
+{
+ r.or_else(|e| match e.code() {
+ git2::ErrorCode::NotFound => f(),
+ _ => Err(e),
+ })
+}
+
+pub fn blob_hash(data: &[u8]) -> Result<git2::Oid> {
+ // very minimally faster than going through libgit2. not sure yet if that's
+ // worth the dependency.
+ #[cfg(feature = "sha1dc")]
+ {
+ use sha1collisiondetection::Sha1CD;
+
+ let mut hasher = Sha1CD::default();
+ hasher.update("blob ");
+ hasher.update(data.len().to_string().as_bytes());
+ hasher.update(b"\0");
+ hasher.update(data);
+ let hash = hasher.finalize_cd().expect("sha1 collision detected");
+ git2::Oid::from_bytes(&hash)
+ }
+ #[cfg(not(feature = "sha1dc"))]
+ git2::Oid::hash_object(git2::ObjectType::Blob, data)
+}
+
+pub fn blob_hash_sha2(data: &[u8]) -> [u8; 32] {
+ let mut hasher = Sha256::new();
+ hasher.update("blob ");
+ hasher.update(data.len().to_string().as_bytes());
+ hasher.update(b"\0");
+ hasher.update(data);
+ hasher.finalize().into()
+}
+
+/// Look up `key` from config and run the value as a command
+pub fn config_command(cfg: &git2::Config, key: &str) -> crate::Result<Option<String>> {
+ if_not_found_none(cfg.get_string(key))?
+ .map(|cmd| {
+ let process::Output { status, stdout, .. } = {
+ let invalid = || anyhow!("'{cmd}' is not a valid command");
+ let lex = shlex::split(&cmd).ok_or_else(invalid)?;
+ let (bin, args) = lex.split_first().ok_or_else(invalid)?;
+ Command::new(bin)
+ .args(args)
+ .stderr(process::Stdio::inherit())
+ .output()?
+ };
+ ensure!(status.success(), "'{cmd}' failed");
+ const NL: u8 = b'\n';
+ let line1 = stdout
+ .into_iter()
+ .take_while(|b| b != &NL)
+ .collect::<Vec<_>>();
+ ensure!(!line1.is_empty(), "no output from '{cmd}'");
+ String::from_utf8(line1).with_context(|| format!("invalid output from '{cmd}'"))
+ })
+ .transpose()
+}
diff --git a/src/git/commit.rs b/src/git/commit.rs
new file mode 100644
index 0000000..cb4a516
--- /dev/null
+++ b/src/git/commit.rs
@@ -0,0 +1,46 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use crate::ssh;
+
+const SSHSIG_NAMESPACE: &str = "git";
+
+pub fn commit_signed<'a, S>(
+ signer: &mut S,
+ repo: &'a git2::Repository,
+ msg: impl AsRef<str>,
+ tree: &git2::Tree<'a>,
+ parents: &[&git2::Commit<'a>],
+) -> crate::Result<git2::Oid>
+where
+ S: crate::keys::Signer + ?Sized,
+{
+ let aut = repo.signature()?;
+ let buf = repo.commit_create_buffer(&aut, &aut, msg.as_ref(), tree, parents)?;
+ let sig = {
+ let hash = ssh::HashAlg::Sha512;
+ let data = ssh::SshSig::signed_data(SSHSIG_NAMESPACE, hash, &buf)?;
+ let sig = signer.sign(&data)?;
+ ssh::SshSig::new(signer.ident().key_data(), SSHSIG_NAMESPACE, hash, sig)?
+ .to_pem(ssh::LineEnding::LF)?
+ };
+ let oid = repo.commit_signed(
+ buf.as_str().expect("commit buffer to be utf8"),
+ sig.as_str(),
+ None,
+ )?;
+
+ Ok(oid)
+}
+
+pub fn verify_commit_signature(
+ repo: &git2::Repository,
+ oid: &git2::Oid,
+) -> crate::Result<ssh::PublicKey> {
+ let (sig, data) = repo.extract_signature(oid, None)?;
+ let sig = ssh::SshSig::from_pem(&*sig)?;
+ let pk = ssh::PublicKey::from(sig.public_key().clone());
+ pk.verify(SSHSIG_NAMESPACE, &data, &sig)?;
+
+ Ok(pk)
+}
diff --git a/src/git/config.rs b/src/git/config.rs
new file mode 100644
index 0000000..bc8dfcc
--- /dev/null
+++ b/src/git/config.rs
@@ -0,0 +1,31 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::ops::Deref;
+
+/// A read-only snapshot of a [`git2::Config`]
+pub struct Snapshot(git2::Config);
+
+impl Deref for Snapshot {
+ type Target = git2::Config;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl TryFrom<git2::Config> for Snapshot {
+ type Error = git2::Error;
+
+ fn try_from(mut cfg: git2::Config) -> Result<Self, Self::Error> {
+ cfg.snapshot().map(Self)
+ }
+}
+
+impl TryFrom<&mut git2::Config> for Snapshot {
+ type Error = git2::Error;
+
+ fn try_from(cfg: &mut git2::Config) -> Result<Self, Self::Error> {
+ cfg.snapshot().map(Self)
+ }
+}
diff --git a/src/git/refs.rs b/src/git/refs.rs
new file mode 100644
index 0000000..5960434
--- /dev/null
+++ b/src/git/refs.rs
@@ -0,0 +1,327 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::{
+ fmt,
+ ops::Deref,
+ str::FromStr,
+};
+use std::{
+ borrow::Cow,
+ cell::Cell,
+ collections::HashMap,
+ path::Path,
+ rc::Rc,
+};
+
+pub const MAX_FILENAME: usize = 255;
+
+#[derive(Clone, Copy)]
+pub struct Options {
+ pub allow_onelevel: bool,
+ pub allow_pattern: bool,
+}
+
+pub mod error {
+ use thiserror::Error;
+
+ #[derive(Debug, Error)]
+ pub enum RefFormat {
+ #[error("empty input")]
+ Empty,
+ #[error("name too long")]
+ NameTooLong,
+ #[error("invalid character {0:?}")]
+ InvalidChar(char),
+ #[error("invalid character sequence {0:?}")]
+ InvalidSeq(&'static str),
+ #[error("must contain at least one '/'")]
+ OneLevel,
+ #[error("must contain at most one '*'")]
+ Pattern,
+ }
+}
+
+pub fn check_ref_format(opts: Options, s: &str) -> Result<(), error::RefFormat> {
+ use error::RefFormat::*;
+
+ match s {
+ "" => Err(Empty),
+ "@" => Err(InvalidChar('@')),
+ "." => Err(InvalidChar('.')),
+ _ => {
+ let mut globs = 0;
+ let mut parts = 0;
+
+ for x in s.split('/') {
+ if x.is_empty() {
+ return Err(InvalidSeq("//"));
+ }
+ if x.len() > MAX_FILENAME {
+ return Err(NameTooLong);
+ }
+
+ parts += 1;
+
+ if x.ends_with(".lock") {
+ return Err(InvalidSeq(".lock"));
+ }
+
+ let last_char = x.len() - 1;
+ for (i, y) in x.chars().zip(x.chars().cycle().skip(1)).enumerate() {
+ match y {
+ ('.', '.') => return Err(InvalidSeq("..")),
+ ('@', '{') => return Err(InvalidSeq("@{")),
+ ('*', _) => globs += 1,
+ (z, _) => match z {
+ '\0' | '\\' | '~' | '^' | ':' | '?' | '[' | ' ' => {
+ return Err(InvalidChar(z))
+ },
+ '.' if i == 0 || i == last_char => return Err(InvalidChar('.')),
+ _ if z.is_ascii_control() => return Err(InvalidChar(z)),
+
+ _ => continue,
+ },
+ }
+ }
+ }
+
+ if parts < 2 && !opts.allow_onelevel {
+ Err(OneLevel)
+ } else if globs > 1 && opts.allow_pattern {
+ Err(Pattern)
+ } else if globs > 0 && !opts.allow_pattern {
+ Err(InvalidChar('*'))
+ } else {
+ Ok(())
+ }
+ },
+ }
+}
+
+/// A valid git refname.
+///
+/// If the input starts with 'refs/`, it is taken verbatim (after validation),
+/// otherwise `refs/heads/' is prepended (ie. the input is considered a branch
+/// name).
+#[derive(
+ Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, ::serde::Serialize, ::serde::Deserialize,
+)]
+#[serde(try_from = "String")]
+pub struct Refname(String);
+
+impl Refname {
+ pub fn main() -> Self {
+ Self("refs/heads/main".into())
+ }
+
+ pub fn master() -> Self {
+ Self("refs/heads/master".into())
+ }
+}
+
+impl fmt::Display for Refname {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self)
+ }
+}
+
+impl Deref for Refname {
+ type Target = str;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl AsRef<str> for Refname {
+ fn as_ref(&self) -> &str {
+ self
+ }
+}
+
+impl AsRef<Path> for Refname {
+ fn as_ref(&self) -> &Path {
+ Path::new(self.0.as_str())
+ }
+}
+
+impl From<Refname> for String {
+ fn from(r: Refname) -> Self {
+ r.0
+ }
+}
+
+impl FromStr for Refname {
+ type Err = error::RefFormat;
+
+ fn from_str(s: &str) -> core::result::Result<Self, Self::Err> {
+ Self::try_from(s.to_owned())
+ }
+}
+
+impl TryFrom<String> for Refname {
+ type Error = error::RefFormat;
+
+ fn try_from(value: String) -> core::result::Result<Self, Self::Error> {
+ const OPTIONS: Options = Options {
+ allow_onelevel: true,
+ allow_pattern: false,
+ };
+
+ check_ref_format(OPTIONS, &value)?;
+ let name = if value.starts_with("refs/") {
+ value
+ } else {
+ format!("refs/heads/{}", value)
+ };
+
+ Ok(Self(name))
+ }
+}
+
+/// Iterator over reference names
+///
+/// [`git2::ReferenceNames`] is advertised as more efficient if only the
+/// reference names are needed, and not a full [`git2::Reference`]. However,
+/// that type has overly restrictive lifetime constraints (because,
+/// inexplicably, it does **not** consume [`git2::References`] even though
+/// the documentation claims so).
+///
+/// We can work around this by transforming the reference `&str` into some other
+/// type which is not subject to its lifetime.
+#[must_use = "iterators are lazy and do nothing unless consumed"]
+pub struct ReferenceNames<'a, F> {
+ inner: git2::References<'a>,
+ trans: F,
+}
+
+impl<'a, F> ReferenceNames<'a, F> {
+ pub fn new(refs: git2::References<'a>, trans: F) -> Self {
+ Self { inner: refs, trans }
+ }
+}
+
+impl<'a, F, E, T> Iterator for ReferenceNames<'a, F>
+where
+ F: FnMut(&str) -> core::result::Result<T, E>,
+ E: From<git2::Error>,
+{
+ type Item = core::result::Result<T, E>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ self.inner
+ .names()
+ .next()
+ .map(|r| r.map_err(E::from).and_then(|name| (self.trans)(name)))
+ }
+}
+
+pub struct Transaction<'a> {
+ tx: git2::Transaction<'a>,
+ locked: HashMap<Refname, Rc<Cell<Op>>>,
+}
+
+impl<'a> Transaction<'a> {
+ pub fn new(repo: &'a git2::Repository) -> super::Result<Self> {
+ let tx = repo.transaction()?;
+ Ok(Self {
+ tx,
+ locked: HashMap::new(),
+ })
+ }
+
+ pub fn lock_ref(&mut self, name: Refname) -> super::Result<LockedRef> {
+ use std::collections::hash_map::Entry;
+
+ let lref = match self.locked.entry(name) {
+ Entry::Vacant(v) => {
+ let name = v.key().clone();
+ self.tx.lock_ref(&name)?;
+ let op = Rc::new(Cell::new(Op::default()));
+ v.insert(Rc::clone(&op));
+ LockedRef { name, op }
+ },
+ Entry::Occupied(v) => LockedRef {
+ name: v.key().clone(),
+ op: Rc::clone(v.get()),
+ },
+ };
+
+ Ok(lref)
+ }
+
+ pub fn commit(mut self) -> super::Result<()> {
+ for (name, op) in self.locked {
+ match op.take() {
+ Op::None => continue,
+ Op::DirTarget { target, reflog } => {
+ self.tx.set_target(&name, target, None, &reflog)?
+ },
+ Op::SymTarget { target, reflog } => {
+ self.tx.set_symbolic_target(&name, &target, None, &reflog)?
+ },
+ Op::Remove => self.tx.remove(&name)?,
+ }
+ }
+ self.tx.commit()
+ }
+}
+
+#[derive(Debug, Default)]
+enum Op {
+ #[default]
+ None,
+ DirTarget {
+ target: git2::Oid,
+ reflog: Cow<'static, str>,
+ },
+ SymTarget {
+ target: Refname,
+ reflog: Cow<'static, str>,
+ },
+ #[allow(unused)]
+ Remove,
+}
+
+pub struct LockedRef {
+ name: Refname,
+ op: Rc<Cell<Op>>,
+}
+
+impl LockedRef {
+ pub fn name(&self) -> &Refname {
+ &self.name
+ }
+
+ pub fn set_target<S: Into<Cow<'static, str>>>(&self, target: git2::Oid, reflog: S) {
+ self.op.set(Op::DirTarget {
+ target,
+ reflog: reflog.into(),
+ })
+ }
+
+ pub fn set_symbolic_target<S: Into<Cow<'static, str>>>(&self, target: Refname, reflog: S) {
+ self.op.set(Op::SymTarget {
+ target,
+ reflog: reflog.into(),
+ })
+ }
+
+ #[allow(unused)]
+ pub fn remove(&self) {
+ self.op.set(Op::Remove)
+ }
+}
+
+impl fmt::Display for LockedRef {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.name())
+ }
+}
+
+impl From<LockedRef> for Refname {
+ fn from(LockedRef { name, .. }: LockedRef) -> Self {
+ name
+ }
+}
diff --git a/src/git/repo.rs b/src/git/repo.rs
new file mode 100644
index 0000000..3fb8a16
--- /dev/null
+++ b/src/git/repo.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::HashSet,
+ ffi::OsString,
+ io::{
+ BufReader,
+ Seek,
+ Write,
+ },
+ iter,
+ path::Path,
+ result::Result as StdResult,
+};
+
+use super::{
+ if_not_found_then,
+ Result,
+};
+use crate::{
+ fs::LockedFile,
+ io::Lines,
+};
+
+pub fn open<P: AsRef<Path>>(path: P) -> Result<git2::Repository> {
+ git2::Repository::open_ext(
+ path,
+ git2::RepositoryOpenFlags::FROM_ENV,
+ iter::empty::<OsString>(),
+ )
+}
+
+pub fn open_bare<P: AsRef<Path>>(path: P) -> Result<git2::Repository> {
+ git2::Repository::open_ext(
+ path,
+ git2::RepositoryOpenFlags::FROM_ENV | git2::RepositoryOpenFlags::BARE,
+ iter::empty::<OsString>(),
+ )
+}
+
+pub fn open_or_init<P: AsRef<Path>>(path: P, opts: InitOpts) -> Result<git2::Repository> {
+ if_not_found_then(open(path.as_ref()), || init(path, opts))
+}
+
+pub struct InitOpts<'a> {
+ pub bare: bool,
+ pub description: &'a str,
+ pub initial_head: &'a str,
+}
+
+pub fn init<P: AsRef<Path>>(path: P, opts: InitOpts) -> Result<git2::Repository> {
+ git2::Repository::init_opts(
+ path,
+ git2::RepositoryInitOptions::new()
+ .no_reinit(true)
+ .mkdir(true)
+ .mkpath(true)
+ .bare(opts.bare)
+ .description(opts.description)
+ .initial_head(opts.initial_head),
+ )
+}
+
+pub fn add_alternates<'a, I>(repo: &git2::Repository, alt: I) -> crate::Result<()>
+where
+ I: IntoIterator<Item = &'a git2::Repository>,
+{
+ let (mut persistent, known) = {
+ let mut lock = LockedFile::atomic(
+ repo.path().join("objects").join("info").join("alternates"),
+ false,
+ LockedFile::DEFAULT_PERMISSIONS,
+ )?;
+ lock.seek(std::io::SeekFrom::Start(0))?;
+ let mut bufread = BufReader::new(lock);
+ let known = Lines::new(&mut bufread).collect::<StdResult<HashSet<String>, _>>()?;
+ (bufread.into_inner(), known)
+ };
+ {
+ let odb = repo.odb()?;
+ for alternate in alt {
+ let path = format!("{}", alternate.path().join("objects").display());
+ odb.add_disk_alternate(&path)?;
+ if !known.contains(&path) {
+ writeln!(&mut persistent, "{}", path)?
+ }
+ }
+ }
+ persistent.persist()?;
+
+ Ok(())
+}
diff --git a/src/git/serde.rs b/src/git/serde.rs
new file mode 100644
index 0000000..e20df47
--- /dev/null
+++ b/src/git/serde.rs
@@ -0,0 +1,61 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::str::FromStr;
+
+use serde::{
+ Deserialize,
+ Deserializer,
+ Serialize,
+ Serializer,
+};
+
+pub mod oid {
+ use super::*;
+
+ #[derive(serde::Serialize, serde::Deserialize)]
+ pub struct Oid(#[serde(with = "self")] pub git2::Oid);
+
+ impl From<git2::Oid> for Oid {
+ fn from(oid: git2::Oid) -> Self {
+ Self(oid)
+ }
+ }
+
+ pub fn serialize<S>(oid: &git2::Oid, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ serializer.serialize_str(&oid.to_string())
+ }
+
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<git2::Oid, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let hex: &str = Deserialize::deserialize(deserializer)?;
+ git2::Oid::from_str(hex).map_err(serde::de::Error::custom)
+ }
+
+ pub mod option {
+ use super::*;
+
+ pub fn serialize<S>(oid: &Option<git2::Oid>, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: Serializer,
+ {
+ oid.as_ref().map(ToString::to_string).serialize(serializer)
+ }
+
+ #[allow(unused)]
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<git2::Oid>, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let hex: Option<&str> = Deserialize::deserialize(deserializer)?;
+ hex.map(FromStr::from_str)
+ .transpose()
+ .map_err(serde::de::Error::custom)
+ }
+ }
+}
diff --git a/src/http.rs b/src/http.rs
new file mode 100644
index 0000000..d52ef8f
--- /dev/null
+++ b/src/http.rs
@@ -0,0 +1,355 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ fs::File,
+ io::Cursor,
+ net::ToSocketAddrs,
+ path::{
+ Path,
+ PathBuf,
+ },
+ sync::{
+ Arc,
+ Mutex,
+ },
+};
+
+use log::{
+ debug,
+ error,
+};
+use once_cell::sync::Lazy;
+use sha2::{
+ Digest,
+ Sha256,
+};
+use threadpool::ThreadPool;
+use tiny_http::{
+ Header,
+ HeaderField,
+ Method,
+ Request,
+ Response,
+ ServerConfig,
+ StatusCode,
+};
+use url::Url;
+
+use crate::{
+ bundle,
+ git,
+ keys,
+ patches::{
+ self,
+ AcceptArgs,
+ AcceptOptions,
+ },
+ ssh::agent,
+};
+
+pub use tiny_http::SslConfig;
+
+pub struct Options {
+ /// Directory of the drop repo
+ pub git_dir: PathBuf,
+ /// Directory from where to serve bundles
+ ///
+ /// Unless absolute, treated as relative to GIT_DIR.
+ pub bundle_dir: PathBuf,
+ /// Ref prefix under which to store the refs contained in patch bundles
+ pub unbundle_prefix: String,
+ /// The refname of the drop history
+ pub drop_ref: String,
+ /// The refname anchoring the seen objects tree
+ pub seen_ref: String,
+ /// Size of the server's threadpool
+ ///
+ /// If `None`, the number of available CPUs is used.
+ pub threads: Option<usize>,
+ /// Certificate and key for `serve`ing over TLS.
+ ///
+ /// It is generally recommended to proxy behind a terminating web server and
+ /// set this to `None`.
+ pub tls: Option<SslConfig>,
+ /// IPFS API to publish received bundles to
+ pub ipfs_api: Option<Url>,
+}
+
+pub fn serve<A>(addr: A, opts: Options) -> !
+where
+ A: ToSocketAddrs,
+{
+ let executor = ThreadPool::new(opts.threads.unwrap_or_else(num_cpus::get));
+ let server = tiny_http::Server::new(ServerConfig {
+ addr,
+ ssl: opts.tls,
+ })
+ .unwrap();
+
+ let repo = git::repo::open(&opts.git_dir).unwrap();
+ let config = repo.config().unwrap();
+
+ let git_dir = repo.path().to_owned();
+ let bundle_dir = if opts.bundle_dir.is_relative() {
+ git_dir.join(opts.bundle_dir)
+ } else {
+ opts.bundle_dir
+ };
+
+ let signer = keys::Agent::from_gitconfig(&config).unwrap();
+
+ let handler = Arc::new(Handler {
+ repo: Mutex::new(repo),
+ signer: Mutex::new(signer),
+ bundle_dir,
+ unbundle_prefix: opts.unbundle_prefix,
+ drop_ref: opts.drop_ref,
+ seen_ref: opts.seen_ref,
+ ipfs_api: opts.ipfs_api,
+ });
+ for req in server.incoming_requests() {
+ let handler = Arc::clone(&handler);
+ executor.execute(move || handler.route(req))
+ }
+
+ panic!("server died unexpectedly");
+}
+
+static CONTENT_TYPE: Lazy<HeaderField> = Lazy::new(|| "Content-Type".parse().unwrap());
+
+static OCTET_STREAM: Lazy<Header> = Lazy::new(|| Header {
+ field: CONTENT_TYPE.clone(),
+ value: "application/octet-stream".parse().unwrap(),
+});
+static TEXT_PLAIN: Lazy<Header> = Lazy::new(|| Header {
+ field: CONTENT_TYPE.clone(),
+ value: "text/plain".parse().unwrap(),
+});
+static JSON: Lazy<Header> = Lazy::new(|| Header {
+ field: CONTENT_TYPE.clone(),
+ value: "application/json".parse().unwrap(),
+});
+static SERVER: Lazy<Header> = Lazy::new(|| Header {
+ field: "Server".parse().unwrap(),
+ value: format!("it/{}", env!("CARGO_PKG_VERSION", "unknown"))
+ .parse()
+ .unwrap(),
+});
+
+enum Resp {
+ Empty {
+ code: StatusCode,
+ },
+ Text {
+ code: StatusCode,
+ body: String,
+ },
+ File {
+ file: File,
+ },
+ Json {
+ code: StatusCode,
+ body: Box<dyn erased_serde::Serialize>,
+ },
+}
+
+impl Resp {
+ const OK: Self = Self::Empty {
+ code: StatusCode(200),
+ };
+ const NOT_FOUND: Self = Self::Empty {
+ code: StatusCode(404),
+ };
+ const METHOD_NOT_ALLOWED: Self = Self::Empty {
+ code: StatusCode(405),
+ };
+ const INTERNAL_SERVER_ERROR: Self = Self::Empty {
+ code: StatusCode(500),
+ };
+
+ fn respond_to(self, req: Request) {
+ let remote_addr = *req.remote_addr();
+ let response = Response::empty(500).with_header(SERVER.clone());
+ let res = match self {
+ Self::Empty { code } => req.respond(response.with_status_code(code)),
+ Self::Text { code, body } => {
+ let len = body.len();
+ req.respond(
+ response
+ .with_status_code(code)
+ .with_header(TEXT_PLAIN.clone())
+ .with_data(Cursor::new(body.into_bytes()), Some(len)),
+ )
+ },
+ Self::File { file } => {
+ let len = file.metadata().ok().and_then(|v| v.len().try_into().ok());
+ req.respond(
+ response
+ .with_status_code(200)
+ .with_header(OCTET_STREAM.clone())
+ .with_data(file, len),
+ )
+ },
+ Self::Json { code, body } => {
+ let json = serde_json::to_vec(&body).unwrap();
+ let len = json.len();
+ req.respond(
+ response
+ .with_status_code(code)
+ .with_header(JSON.clone())
+ .with_data(Cursor::new(json), Some(len)),
+ )
+ },
+ };
+
+ if let Err(e) = res {
+ error!("failed to send response to {remote_addr}: {e}");
+ }
+ }
+}
+
+impl From<StatusCode> for Resp {
+ fn from(code: StatusCode) -> Self {
+ Self::Empty { code }
+ }
+}
+
+struct Handler {
+ repo: Mutex<git2::Repository>,
+ signer: Mutex<keys::Agent<agent::UnixStream>>,
+ bundle_dir: PathBuf,
+ unbundle_prefix: String,
+ drop_ref: String,
+ seen_ref: String,
+ ipfs_api: Option<Url>,
+}
+
+impl Handler {
+ fn route(&self, mut req: Request) {
+ use Method::*;
+
+ debug!("{} {}", req.method(), req.url());
+ let resp = match req.method() {
+ Get => match &request_target(&req)[..] {
+ ["-", "status"] => Resp::OK,
+ ["bundles", hash] => self.get_bundle(hash),
+ _ => Resp::NOT_FOUND,
+ },
+
+ Post => match &request_target(&req)[..] {
+ ["patches"] => self.post_patch(&mut req),
+ _ => Resp::NOT_FOUND,
+ },
+
+ _ => Resp::METHOD_NOT_ALLOWED,
+ };
+
+ resp.respond_to(req)
+ }
+
+ fn get_bundle(&self, hash: &str) -> Resp {
+ fn base_path(root: &Path, s: &str) -> Result<PathBuf, Resp> {
+ bundle::Hash::is_valid(s)
+ .then(|| root.join(s))
+ .ok_or_else(|| Resp::Text {
+ code: 400.into(),
+ body: "invalid bundle hash".into(),
+ })
+ }
+
+ if let Some(hash) = hash.strip_suffix(bundle::list::DOT_FILE_EXTENSION) {
+ base_path(&self.bundle_dir, hash).map_or_else(
+ |x| x,
+ |base| {
+ let path = base.with_extension(bundle::list::FILE_EXTENSION);
+ if !path.exists() && base.with_extension(bundle::FILE_EXTENSION).exists() {
+ default_bundle_list(hash)
+ } else {
+ serve_file(path)
+ }
+ },
+ )
+ } else if let Some(hash) = hash.strip_suffix(bundle::DOT_FILE_EXTENSION) {
+ base_path(&self.bundle_dir, hash).map_or_else(
+ |x| x,
+ |mut path| {
+ path.set_extension(bundle::FILE_EXTENSION);
+ serve_file(path)
+ },
+ )
+ } else {
+ base_path(&self.bundle_dir, hash).map_or_else(
+ |x| x,
+ |mut base| {
+ base.set_extension(bundle::FILE_EXTENSION);
+ if !base.exists() {
+ base.set_extension(bundle::list::FILE_EXTENSION);
+ }
+ serve_file(base)
+ },
+ )
+ }
+ }
+
+ fn post_patch(&self, req: &mut Request) -> Resp {
+ patches::Submission::from_http(&self.bundle_dir, req)
+ .and_then(|mut sub| {
+ let repo = self.repo.lock().unwrap();
+ let mut signer = self.signer.lock().unwrap();
+ sub.try_accept(AcceptArgs {
+ unbundle_prefix: &self.unbundle_prefix,
+ drop_ref: &self.drop_ref,
+ seen_ref: &self.seen_ref,
+ repo: &repo,
+ signer: &mut *signer,
+ ipfs_api: self.ipfs_api.as_ref(),
+ options: AcceptOptions::default(),
+ })
+ })
+ .map(|record| Resp::Json {
+ code: 200.into(),
+ body: Box::new(record),
+ })
+ .unwrap_or_else(|e| Resp::Text {
+ code: 400.into(),
+ body: e.to_string(),
+ })
+ }
+}
+
+// We've been calling this "request URL", but acc. to RFC7230 it is the
+// "request-target".
+fn request_target(req: &Request) -> Vec<&str> {
+ req.url().split('/').filter(|s| !s.is_empty()).collect()
+}
+
+fn serve_file<P: AsRef<Path>>(path: P) -> Resp {
+ let path = path.as_ref();
+ if path.exists() {
+ File::open(path)
+ .map(|file| Resp::File { file })
+ .unwrap_or_else(|e| {
+ error!("failed to open file {}: {e}", path.display());
+ Resp::INTERNAL_SERVER_ERROR
+ })
+ } else {
+ Resp::NOT_FOUND
+ }
+}
+
+fn default_bundle_list(hash: &str) -> Resp {
+ let uri = bundle::Uri::Relative(format!("/bundle/{}.bundle", hash));
+ let id = hex::encode(Sha256::digest(uri.as_str()));
+
+ let body = bundle::List {
+ bundles: vec![bundle::Location::new(id, uri)],
+ ..bundle::List::any()
+ }
+ .to_str();
+
+ Resp::Text {
+ code: 200.into(),
+ body,
+ }
+}
diff --git a/src/io.rs b/src/io.rs
new file mode 100644
index 0000000..86f91c6
--- /dev/null
+++ b/src/io.rs
@@ -0,0 +1,146 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use sha2::{
+ digest::generic_array::GenericArray,
+ Digest,
+};
+
+/// Created by [`Lines::until_blank`], stops iteration at the first blank line.
+pub struct UntilBlank<B> {
+ inner: Lines<B>,
+}
+
+impl<B: std::io::BufRead> Iterator for UntilBlank<B> {
+ type Item = std::io::Result<String>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ self.inner.next().and_then(|res| match res {
+ Ok(line) => {
+ if line.is_empty() {
+ None
+ } else {
+ Some(Ok(line))
+ }
+ },
+ Err(e) => Some(Err(e)),
+ })
+ }
+}
+
+impl<B: std::io::Seek> std::io::Seek for UntilBlank<B> {
+ fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
+ self.inner.seek(pos)
+ }
+}
+
+/// Like [`std::io::Lines`], but allows to retain ownership of the underlying
+/// [`std::io::BufRead`].
+pub struct Lines<B> {
+ buf: B,
+}
+
+impl<B: std::io::BufRead> Lines<B> {
+ pub fn new(buf: B) -> Self {
+ Self { buf }
+ }
+
+ pub fn until_blank(self) -> UntilBlank<B> {
+ UntilBlank { inner: self }
+ }
+}
+
+impl<B: std::io::BufRead> Iterator for Lines<B> {
+ type Item = std::io::Result<String>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let mut buf = String::new();
+ match self.buf.read_line(&mut buf) {
+ Ok(0) => None,
+ Ok(_) => {
+ if buf.ends_with('\n') {
+ buf.pop();
+ if buf.ends_with('\r') {
+ buf.pop();
+ }
+ }
+ Some(Ok(buf))
+ },
+ Err(e) => Some(Err(e)),
+ }
+ }
+}
+
+impl<B: std::io::Seek> std::io::Seek for Lines<B> {
+ fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
+ self.buf.seek(pos)
+ }
+}
+
+/// A [`std::io::Write`] which also computes a hash digest from the bytes
+/// written to it.
+pub struct HashWriter<D, W> {
+ hasher: D,
+ writer: W,
+}
+
+impl<D, W> HashWriter<D, W> {
+ pub fn new(hasher: D, writer: W) -> Self {
+ Self { hasher, writer }
+ }
+}
+
+impl<D, W> HashWriter<D, W>
+where
+ D: Digest,
+{
+ pub fn hash(self) -> GenericArray<u8, D::OutputSize> {
+ self.hasher.finalize()
+ }
+}
+
+impl<D, W> std::io::Write for HashWriter<D, W>
+where
+ D: Digest,
+ W: std::io::Write,
+{
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+ self.hasher.update(buf);
+ self.writer.write(buf)
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ self.writer.flush()
+ }
+}
+
+/// A [`std::io::Write`] which keeps track of the number of bytes written to it
+pub struct LenWriter<W> {
+ written: u64,
+ writer: W,
+}
+
+impl<W> LenWriter<W> {
+ pub fn new(writer: W) -> Self {
+ Self { written: 0, writer }
+ }
+
+ pub fn bytes_written(&self) -> u64 {
+ self.written
+ }
+}
+
+impl<W> std::io::Write for LenWriter<W>
+where
+ W: std::io::Write,
+{
+ fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
+ let n = self.writer.write(buf)?;
+ self.written += n as u64;
+ Ok(n)
+ }
+
+ fn flush(&mut self) -> std::io::Result<()> {
+ self.writer.flush()
+ }
+}
diff --git a/src/iter.rs b/src/iter.rs
new file mode 100644
index 0000000..1289c52
--- /dev/null
+++ b/src/iter.rs
@@ -0,0 +1,109 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::ops::ControlFlow;
+
+/// Iterator with a lazy fallible initialiser
+///
+/// It is a common pattern that instantiating an effectful iterator is fallible,
+/// while traversing it is fallible, too. This yields unwieldy signatures like:
+///
+/// ```no_run
+/// fn my_iterator() -> Result<impl Iterator<Item = Result<T, F>>, E>
+/// ```
+///
+/// Often, however, we can unify the error types (`E` and `F` above), which
+/// allows for the more pleasant pattern that constructing the iterator is
+/// infallible, but an initialiser error is returned upon the first call to
+/// `next()`. Ie.:
+///
+/// ```no_run
+/// fn my_iterator() -> impl Iterator<Item = Result<T, E>>
+/// ```
+#[must_use = "iterators are lazy and do nothing unless consumed"]
+pub struct Iter<E, F, I, G> {
+ init: Option<F>,
+ iter: Option<Result<I, E>>,
+ next: G,
+}
+
+impl<E, F, I, G> Iter<E, F, I, G> {
+ pub fn new(init: F, next: G) -> Self {
+ Self {
+ init: Some(init),
+ iter: None,
+ next,
+ }
+ }
+}
+
+impl<E, F, I, G, T, U> Iterator for Iter<E, F, I, G>
+where
+ F: FnOnce() -> Result<I, E>,
+ I: Iterator<Item = Result<T, E>>,
+ G: FnMut(Result<T, E>) -> Option<Result<U, E>>,
+{
+ type Item = Result<U, E>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ match self.iter.take() {
+ None => {
+ let init = self.init.take()?;
+ self.iter = Some(init());
+ self.next()
+ },
+ Some(Err(e)) => Some(Err(e)),
+ Some(Ok(mut iter)) => {
+ let item = iter.next()?;
+ let next = (self.next)(item);
+ self.iter = Some(Ok(iter));
+ next
+ },
+ }
+ }
+}
+
+impl<E, F, I, G, T, U> DoubleEndedIterator for Iter<E, F, I, G>
+where
+ F: FnOnce() -> Result<I, E>,
+ I: Iterator<Item = Result<T, E>> + DoubleEndedIterator,
+ G: FnMut(Result<T, E>) -> Option<Result<U, E>>,
+{
+ fn next_back(&mut self) -> Option<Self::Item> {
+ match self.iter.take() {
+ None => {
+ let init = self.init.take()?;
+ self.iter = Some(init());
+ self.next_back()
+ },
+ Some(Err(e)) => Some(Err(e)),
+ Some(Ok(mut iter)) => {
+ let item = iter.next_back()?;
+ let next = (self.next)(item);
+ self.iter = Some(Ok(iter));
+ next
+ },
+ }
+ }
+}
+
+pub(crate) trait IteratorExt {
+ fn try_find_map<F, T, E>(&mut self, mut f: F) -> crate::Result<Option<T>>
+ where
+ Self: Iterator + Sized,
+ F: FnMut(Self::Item) -> Result<Option<T>, E>,
+ E: Into<crate::Error>,
+ {
+ let x = self.try_fold((), |(), i| match f(i) {
+ Err(e) => ControlFlow::Break(Err(e.into())),
+ Ok(v) if v.is_some() => ControlFlow::Break(Ok(v)),
+ Ok(_) => ControlFlow::Continue(()),
+ });
+ match x {
+ ControlFlow::Continue(()) => Ok(None),
+ ControlFlow::Break(v) => v,
+ }
+ }
+}
+
+impl<T: Iterator> IteratorExt for T {}
diff --git a/src/json.rs b/src/json.rs
new file mode 100644
index 0000000..52f6215
--- /dev/null
+++ b/src/json.rs
@@ -0,0 +1,49 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ fs::File,
+ io::BufReader,
+ path::Path,
+};
+
+use serde::{
+ de::DeserializeOwned,
+ Deserialize,
+ Serialize,
+};
+
+pub mod canonical;
+
+pub fn from_blob<'a, T>(blob: &'a git2::Blob) -> crate::Result<T>
+where
+ T: Deserialize<'a>,
+{
+ Ok(serde_json::from_slice(blob.content())?)
+}
+
+pub fn to_blob<T>(repo: &git2::Repository, data: &T) -> crate::Result<git2::Oid>
+where
+ T: Serialize,
+{
+ let mut writer = repo.blob_writer(None)?;
+ serde_json::to_writer_pretty(&mut writer, data)?;
+ Ok(writer.commit()?)
+}
+
+pub fn from_file<P, T>(path: P) -> crate::Result<T>
+where
+ P: AsRef<Path>,
+ T: DeserializeOwned,
+{
+ let file = File::open(path)?;
+ Ok(serde_json::from_reader(BufReader::new(file))?)
+}
+
+pub fn load<P, T>(path: P) -> crate::Result<T>
+where
+ P: AsRef<Path>,
+ T: DeserializeOwned,
+{
+ from_file(path)
+}
diff --git a/src/json/canonical.rs b/src/json/canonical.rs
new file mode 100644
index 0000000..6de9517
--- /dev/null
+++ b/src/json/canonical.rs
@@ -0,0 +1,166 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::BTreeMap,
+ io::Write,
+};
+
+use unicode_normalization::{
+ is_nfc_quick,
+ IsNormalized,
+ UnicodeNormalization as _,
+};
+
+use crate::metadata;
+
+pub mod error {
+ use std::io;
+
+ use thiserror::Error;
+
+ #[derive(Debug, Error)]
+ pub enum Canonicalise {
+ #[error(transparent)]
+ Cjson(#[from] Float),
+
+ #[error(transparent)]
+ Json(#[from] serde_json::Error),
+
+ #[error(transparent)]
+ Io(#[from] io::Error),
+ }
+
+ #[derive(Debug, Error)]
+ #[error("cannot canonicalise floating-point number")]
+ pub struct Float;
+}
+
+pub(crate) enum Value {
+ Null,
+ Bool(bool),
+ Number(Number),
+ String(String),
+ Array(Vec<Value>),
+ Object(BTreeMap<String, Value>),
+}
+
+impl TryFrom<&serde_json::Value> for Value {
+ type Error = error::Float;
+
+ fn try_from(js: &serde_json::Value) -> Result<Self, Self::Error> {
+ match js {
+ serde_json::Value::Null => Ok(Self::Null),
+ serde_json::Value::Bool(b) => Ok(Self::Bool(*b)),
+ serde_json::Value::Number(n) => n
+ .as_i64()
+ .map(Number::I64)
+ .or_else(|| n.as_u64().map(Number::U64))
+ .map(Self::Number)
+ .ok_or(error::Float),
+ serde_json::Value::String(s) => Ok(Self::String(to_nfc(s))),
+ serde_json::Value::Array(v) => {
+ let mut out = Vec::with_capacity(v.len());
+ for w in v.iter().map(TryFrom::try_from) {
+ out.push(w?);
+ }
+ Ok(Self::Array(out))
+ },
+ serde_json::Value::Object(m) => {
+ let mut out = BTreeMap::new();
+ for (k, v) in m {
+ out.insert(to_nfc(k), Self::try_from(v)?);
+ }
+ Ok(Self::Object(out))
+ },
+ }
+ }
+}
+
+impl TryFrom<&metadata::Custom> for Value {
+ type Error = error::Float;
+
+ fn try_from(js: &metadata::Custom) -> Result<Self, Self::Error> {
+ let mut out = BTreeMap::new();
+ for (k, v) in js {
+ out.insert(to_nfc(k), Self::try_from(v)?);
+ }
+ Ok(Self::Object(out))
+ }
+}
+
+impl serde::Serialize for Value {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ match self {
+ Value::Null => serializer.serialize_unit(),
+ Value::Bool(b) => serializer.serialize_bool(*b),
+ Value::Number(n) => n.serialize(serializer),
+ Value::String(s) => serializer.serialize_str(s),
+ Value::Array(v) => v.serialize(serializer),
+ Value::Object(m) => {
+ use serde::ser::SerializeMap;
+
+ let mut map = serializer.serialize_map(Some(m.len()))?;
+ for (k, v) in m {
+ map.serialize_entry(k, v)?;
+ }
+ map.end()
+ },
+ }
+ }
+}
+
+pub(crate) enum Number {
+ I64(i64),
+ U64(u64),
+}
+
+impl serde::Serialize for Number {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ match self {
+ Number::I64(n) => serializer.serialize_i64(*n),
+ Number::U64(n) => serializer.serialize_u64(*n),
+ }
+ }
+}
+
+fn to_nfc(s: &String) -> String {
+ match is_nfc_quick(s.chars()) {
+ IsNormalized::Yes => s.clone(),
+ IsNormalized::No | IsNormalized::Maybe => s.nfc().collect(),
+ }
+}
+
+pub fn to_writer<W, T>(out: W, v: T) -> Result<(), error::Canonicalise>
+where
+ W: Write,
+ T: serde::Serialize,
+{
+ let js = serde_json::to_value(v)?;
+ let cj = Value::try_from(&js)?;
+ serde_json::to_writer(out, &cj).map_err(|e| {
+ if e.is_io() {
+ error::Canonicalise::Io(e.into())
+ } else {
+ error::Canonicalise::Json(e)
+ }
+ })?;
+
+ Ok(())
+}
+
+pub fn to_vec<T>(v: T) -> Result<Vec<u8>, error::Canonicalise>
+where
+ T: serde::Serialize,
+{
+ let mut buf = Vec::new();
+ to_writer(&mut buf, v)?;
+
+ Ok(buf)
+}
diff --git a/src/keys.rs b/src/keys.rs
new file mode 100644
index 0000000..c6be894
--- /dev/null
+++ b/src/keys.rs
@@ -0,0 +1,206 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::fmt;
+use std::{
+ borrow::Cow,
+ io,
+ ops::{
+ Deref,
+ DerefMut,
+ },
+ str::FromStr,
+};
+
+use anyhow::anyhow;
+use signature::SignerMut;
+
+use crate::{
+ cfg,
+ metadata,
+ ssh::{
+ self,
+ agent,
+ },
+};
+
+pub type Signature = ssh::Signature;
+
+pub trait Signer {
+ fn ident(&self) -> VerificationKey;
+ fn sign(&mut self, msg: &[u8]) -> Result<ssh::Signature, signature::Error>;
+}
+
+impl<T> Signer for Box<T>
+where
+ T: Signer + ?Sized,
+{
+ fn ident(&self) -> VerificationKey {
+ self.deref().ident()
+ }
+
+ fn sign(&mut self, msg: &[u8]) -> Result<ssh::Signature, signature::Error> {
+ self.deref_mut().sign(msg)
+ }
+}
+
+impl Signer for ssh::PrivateKey {
+ fn ident(&self) -> VerificationKey {
+ self.public_key().into()
+ }
+
+ fn sign(&mut self, msg: &[u8]) -> Result<ssh::Signature, signature::Error> {
+ self.try_sign(msg)
+ }
+}
+
+pub struct Agent<T> {
+ client: agent::Client<T>,
+ ident: ssh::PublicKey,
+}
+
+impl Agent<agent::UnixStream> {
+ pub fn from_gitconfig(cfg: &git2::Config) -> crate::Result<Self> {
+ let client = agent::Client::from_env()?;
+ let ident = VerificationKey::from_gitconfig(cfg)?.0.into_owned();
+
+ Ok(Self { client, ident })
+ }
+
+ pub fn boxed(self) -> Box<dyn Signer> {
+ Box::new(self)
+ }
+
+ pub fn as_dyn(&mut self) -> &mut dyn Signer {
+ self
+ }
+}
+
+impl<T> Agent<T> {
+ pub fn new(client: agent::Client<T>, key: VerificationKey<'_>) -> Self {
+ let ident = key.0.into_owned();
+ Self { client, ident }
+ }
+
+ pub fn verification_key(&self) -> VerificationKey {
+ VerificationKey::from(&self.ident)
+ }
+}
+
+impl<T> Signer for Agent<T>
+where
+ T: io::Read + io::Write,
+{
+ fn ident(&self) -> VerificationKey {
+ self.verification_key()
+ }
+
+ fn sign(&mut self, msg: &[u8]) -> Result<ssh::Signature, signature::Error> {
+ self.client
+ .sign(&self.ident, msg)
+ .map_err(signature::Error::from_source)
+ }
+}
+
+impl<T> Signer for &mut Agent<T>
+where
+ T: io::Read + io::Write,
+{
+ fn ident(&self) -> VerificationKey {
+ self.verification_key()
+ }
+
+ fn sign(&mut self, msg: &[u8]) -> Result<ssh::Signature, signature::Error> {
+ self.client
+ .sign(&self.ident, msg)
+ .map_err(signature::Error::from_source)
+ }
+}
+
+#[derive(Clone)]
+pub struct VerificationKey<'a>(Cow<'a, ssh::PublicKey>);
+
+impl<'a> VerificationKey<'a> {
+ pub fn from_openssh(key: &str) -> Result<Self, ssh::Error> {
+ ssh::PublicKey::from_openssh(key).map(Cow::Owned).map(Self)
+ }
+
+ pub fn to_openssh(&self) -> Result<String, ssh::Error> {
+ self.0.to_openssh()
+ }
+
+ pub fn from_gitconfig(cfg: &git2::Config) -> crate::Result<Self> {
+ let key = cfg::git::signing_key(cfg)?
+ .ok_or_else(|| anyhow!("unable to determine signing key from git config"))?
+ .public()
+ .to_owned();
+ Ok(Self(Cow::Owned(key)))
+ }
+
+ pub fn algorithm(&self) -> ssh::Algorithm {
+ self.0.algorithm()
+ }
+
+ pub fn strip_comment(&mut self) {
+ self.0.to_mut().set_comment("")
+ }
+
+ pub fn without_comment(mut self) -> Self {
+ self.strip_comment();
+ self
+ }
+
+ pub fn sha256(&self) -> [u8; 32] {
+ self.0.fingerprint(ssh::HashAlg::Sha256).sha256().unwrap()
+ }
+
+ pub fn to_owned<'b>(&self) -> VerificationKey<'b> {
+ VerificationKey(Cow::Owned(self.0.clone().into_owned()))
+ }
+
+ pub fn keyid(&self) -> metadata::KeyId {
+ metadata::KeyId::from(self)
+ }
+
+ pub(crate) fn key_data(&self) -> ssh::public::KeyData {
+ self.as_ref().into()
+ }
+}
+
+impl AsRef<ssh::PublicKey> for VerificationKey<'_> {
+ fn as_ref(&self) -> &ssh::PublicKey {
+ &self.0
+ }
+}
+
+impl fmt::Display for VerificationKey<'_> {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str(&self.0.to_string())
+ }
+}
+
+impl From<ssh::PublicKey> for VerificationKey<'_> {
+ fn from(key: ssh::PublicKey) -> Self {
+ Self(Cow::Owned(key))
+ }
+}
+
+impl<'a> From<&'a ssh::PublicKey> for VerificationKey<'a> {
+ fn from(key: &'a ssh::PublicKey) -> Self {
+ Self(Cow::Borrowed(key))
+ }
+}
+
+impl FromStr for VerificationKey<'_> {
+ type Err = ssh::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Self::from_openssh(s)
+ }
+}
+
+impl signature::Verifier<ssh::Signature> for VerificationKey<'_> {
+ fn verify(&self, msg: &[u8], signature: &ssh::Signature) -> Result<(), signature::Error> {
+ signature::Verifier::verify(&*self.0, msg, signature)
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..789f99f
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,33 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+mod bundle;
+mod cfg;
+mod fs;
+mod git;
+mod http;
+mod io;
+mod iter;
+mod json;
+mod keys;
+mod metadata;
+mod patches;
+mod serde;
+mod ssh;
+mod str;
+
+pub const SPEC_VERSION: metadata::SpecVersion = metadata::SpecVersion::current();
+
+pub mod cmd;
+pub use cmd::{
+ ui::Output,
+ Cmd,
+};
+
+pub mod error;
+pub use error::{
+ Error,
+ Result,
+};
+
+pub use cfg::paths;
diff --git a/src/metadata.rs b/src/metadata.rs
new file mode 100644
index 0000000..9caee96
--- /dev/null
+++ b/src/metadata.rs
@@ -0,0 +1,749 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::{
+ convert::TryFrom,
+ fmt,
+ ops::Deref,
+ str::FromStr,
+};
+use std::{
+ borrow::Cow,
+ collections::BTreeMap,
+ io,
+ marker::PhantomData,
+ ops::DerefMut,
+};
+
+use serde::ser::SerializeSeq;
+use sha2::{
+ Digest,
+ Sha512,
+};
+use time::{
+ Duration,
+ OffsetDateTime,
+ UtcOffset,
+};
+use versions::SemVer;
+
+use crate::{
+ git::blob_hash_sha2,
+ json::canonical,
+ keys::{
+ Signer,
+ VerificationKey,
+ },
+ ssh,
+};
+
+pub mod drop;
+pub use drop::Drop;
+
+pub mod error;
+pub mod git;
+
+mod mirrors;
+pub use mirrors::{
+ Alternates,
+ Mirrors,
+};
+
+pub mod identity;
+pub use identity::{
+ Identity,
+ IdentityId,
+};
+
+#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)]
+pub struct SpecVersion(SemVer);
+
+impl SpecVersion {
+ pub const fn current() -> Self {
+ Self::new(0, 1, 0)
+ }
+
+ const fn new(major: u32, minor: u32, patch: u32) -> Self {
+ Self(SemVer {
+ major,
+ minor,
+ patch,
+ pre_rel: None,
+ meta: None,
+ })
+ }
+
+ /// This spec version is compatible if its major version is greater than or
+ /// equal to `other`'s
+ pub fn is_compatible(&self, other: &Self) -> bool {
+ self.0.major >= other.major()
+ }
+
+ pub fn major(&self) -> u32 {
+ self.0.major
+ }
+
+ pub fn minor(&self) -> u32 {
+ self.0.minor
+ }
+
+ pub fn patch(&self) -> u32 {
+ self.0.patch
+ }
+}
+
+impl Default for SpecVersion {
+ fn default() -> Self {
+ Self::current()
+ }
+}
+
+impl fmt::Display for SpecVersion {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+impl FromStr for SpecVersion {
+ type Err = <SemVer as FromStr>::Err;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ SemVer::from_str(s).map(Self)
+ }
+}
+
+impl<'a> TryFrom<&'a str> for SpecVersion {
+ type Error = <SemVer as TryFrom<&'a str>>::Error;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ SemVer::try_from(value).map(Self)
+ }
+}
+
+impl AsRef<SemVer> for SpecVersion {
+ fn as_ref(&self) -> &SemVer {
+ &self.0
+ }
+}
+
+impl serde::Serialize for SpecVersion {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> serde::Deserialize<'de> for SpecVersion {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let s: &str = serde::Deserialize::deserialize(deserializer)?;
+ Self::try_from(s).map_err(|_| serde::de::Error::custom("invalid version string"))
+ }
+}
+
+pub type Custom = serde_json::Map<String, serde_json::Value>;
+
+#[derive(
+ Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Hash, serde::Serialize, serde::Deserialize,
+)]
+pub struct KeyId(#[serde(with = "hex::serde")] [u8; 32]);
+
+impl KeyId {
+ pub fn as_bytes(&self) -> &[u8] {
+ self.as_ref()
+ }
+}
+
+impl AsRef<[u8]> for KeyId {
+ fn as_ref(&self) -> &[u8] {
+ &self.0
+ }
+}
+
+impl From<&Key<'_>> for KeyId {
+ fn from(key: &Key<'_>) -> Self {
+ Self::from(&key.0)
+ }
+}
+
+impl From<Key<'_>> for KeyId {
+ fn from(key: Key<'_>) -> Self {
+ Self::from(key.0)
+ }
+}
+
+impl From<&VerificationKey<'_>> for KeyId {
+ fn from(key: &VerificationKey<'_>) -> Self {
+ Self(key.sha256())
+ }
+}
+
+impl From<VerificationKey<'_>> for KeyId {
+ fn from(key: VerificationKey<'_>) -> Self {
+ Self(key.sha256())
+ }
+}
+
+impl fmt::Display for KeyId {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str(&hex::encode(self.0))
+ }
+}
+
+impl fmt::Debug for KeyId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_tuple("KeyId").field(&hex::encode(self.0)).finish()
+ }
+}
+
+#[derive(Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
+pub struct ContentHash {
+ #[serde(with = "hex::serde")]
+ pub sha1: [u8; 20],
+ #[serde(with = "hex::serde")]
+ pub sha2: [u8; 32],
+}
+
+impl ContentHash {
+ pub fn as_oid(&self) -> git2::Oid {
+ self.into()
+ }
+}
+
+impl From<&git2::Blob<'_>> for ContentHash {
+ fn from(blob: &git2::Blob) -> Self {
+ let sha1 = blob
+ .id()
+ .as_bytes()
+ .try_into()
+ .expect("libgit2 to support only sha1 oids");
+ let sha2 = blob_hash_sha2(blob.content());
+
+ Self { sha1, sha2 }
+ }
+}
+
+impl From<&ContentHash> for git2::Oid {
+ fn from(ContentHash { sha1, .. }: &ContentHash) -> Self {
+ Self::from_bytes(sha1).expect("20 bytes are a valid git2::Oid")
+ }
+}
+
+impl PartialEq<git2::Oid> for ContentHash {
+ fn eq(&self, other: &git2::Oid) -> bool {
+ self.sha1.as_slice() == other.as_bytes()
+ }
+}
+
+impl fmt::Debug for ContentHash {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.debug_struct("ContentHash")
+ .field("sha1", &hex::encode(self.sha1))
+ .field("sha2", &hex::encode(self.sha2))
+ .finish()
+ }
+}
+
+impl fmt::Display for ContentHash {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str(&hex::encode(self.sha1))
+ }
+}
+
+#[derive(
+ Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize,
+)]
+pub struct DateTime(#[serde(with = "time::serde::rfc3339")] OffsetDateTime);
+
+impl DateTime {
+ pub fn now() -> Self {
+ Self(time::OffsetDateTime::now_utc())
+ }
+
+ pub const fn checked_add(self, duration: Duration) -> Option<Self> {
+ // `map` is not const yet
+ match self.0.checked_add(duration) {
+ None => None,
+ Some(x) => Some(Self(x)),
+ }
+ }
+}
+
+impl FromStr for DateTime {
+ type Err = time::error::Parse;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
+ .map(|dt| dt.to_offset(UtcOffset::UTC))
+ .map(Self)
+ }
+}
+
+impl Deref for DateTime {
+ type Target = time::OffsetDateTime;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+#[serde(tag = "_type")]
+pub enum Metadata<'a> {
+ #[serde(rename = "eagain.io/it/identity")]
+ Identity(Cow<'a, Identity>),
+ #[serde(rename = "eagain.io/it/drop")]
+ Drop(Cow<'a, Drop>),
+ #[serde(rename = "eagain.io/it/mirrors")]
+ Mirrors(Cow<'a, Mirrors>),
+ #[serde(rename = "eagain.io/it/alternates")]
+ Alternates(Cow<'a, Alternates>),
+}
+
+impl<'a> Metadata<'a> {
+ pub fn identity<T>(s: T) -> Self
+ where
+ T: Into<Cow<'a, Identity>>,
+ {
+ Self::Identity(s.into())
+ }
+
+ pub fn drop<T>(d: T) -> Self
+ where
+ T: Into<Cow<'a, Drop>>,
+ {
+ Self::Drop(d.into())
+ }
+
+ pub fn mirrors<T>(a: T) -> Self
+ where
+ T: Into<Cow<'a, Mirrors>>,
+ {
+ Self::Mirrors(a.into())
+ }
+
+ pub fn alternates<T>(a: T) -> Self
+ where
+ T: Into<Cow<'a, Alternates>>,
+ {
+ Self::Alternates(a.into())
+ }
+
+ pub fn sign<'b, I, S>(self, keys: I) -> crate::Result<Signed<Self>>
+ where
+ I: IntoIterator<Item = &'b mut S>,
+ S: Signer + ?Sized + 'b,
+ {
+ let payload = Sha512::digest(canonical::to_vec(&self)?);
+ let signatures = keys
+ .into_iter()
+ .map(|signer| {
+ let keyid = KeyId::from(signer.ident());
+ let sig = signer.sign(&payload)?;
+ Ok::<_, crate::Error>((keyid, Signature::from(sig)))
+ })
+ .collect::<Result<_, _>>()?;
+
+ Ok(Signed {
+ signed: self,
+ signatures,
+ })
+ }
+}
+
+impl From<Identity> for Metadata<'static> {
+ fn from(s: Identity) -> Self {
+ Self::identity(s)
+ }
+}
+
+impl<'a> From<&'a Identity> for Metadata<'a> {
+ fn from(s: &'a Identity) -> Self {
+ Self::identity(s)
+ }
+}
+
+impl From<Drop> for Metadata<'static> {
+ fn from(d: Drop) -> Self {
+ Self::drop(d)
+ }
+}
+
+impl<'a> From<&'a Drop> for Metadata<'a> {
+ fn from(d: &'a Drop) -> Self {
+ Self::drop(d)
+ }
+}
+
+impl From<Mirrors> for Metadata<'static> {
+ fn from(m: Mirrors) -> Self {
+ Self::mirrors(m)
+ }
+}
+
+impl<'a> From<&'a Mirrors> for Metadata<'a> {
+ fn from(m: &'a Mirrors) -> Self {
+ Self::mirrors(m)
+ }
+}
+
+impl From<Alternates> for Metadata<'static> {
+ fn from(a: Alternates) -> Self {
+ Self::alternates(a)
+ }
+}
+
+impl<'a> From<&'a Alternates> for Metadata<'a> {
+ fn from(a: &'a Alternates) -> Self {
+ Self::alternates(a)
+ }
+}
+
+impl<'a> TryFrom<Metadata<'a>> for Cow<'a, Identity> {
+ type Error = Metadata<'a>;
+
+ fn try_from(value: Metadata<'a>) -> Result<Self, Self::Error> {
+ match value {
+ Metadata::Identity(inner) => Ok(inner),
+ _ => Err(value),
+ }
+ }
+}
+
+impl<'a> TryFrom<Metadata<'a>> for Cow<'a, Drop> {
+ type Error = Metadata<'a>;
+
+ fn try_from(value: Metadata<'a>) -> Result<Self, Self::Error> {
+ match value {
+ Metadata::Drop(inner) => Ok(inner),
+ _ => Err(value),
+ }
+ }
+}
+
+impl<'a> TryFrom<Metadata<'a>> for Cow<'a, Mirrors> {
+ type Error = Metadata<'a>;
+
+ fn try_from(value: Metadata<'a>) -> Result<Self, Self::Error> {
+ match value {
+ Metadata::Mirrors(inner) => Ok(inner),
+ _ => Err(value),
+ }
+ }
+}
+
+impl<'a> TryFrom<Metadata<'a>> for Cow<'a, Alternates> {
+ type Error = Metadata<'a>;
+
+ fn try_from(value: Metadata<'a>) -> Result<Self, Self::Error> {
+ match value {
+ Metadata::Alternates(inner) => Ok(inner),
+ _ => Err(value),
+ }
+ }
+}
+
+#[derive(Clone, serde::Serialize, serde::Deserialize)]
+pub struct Signed<T> {
+ pub signed: T,
+ pub signatures: BTreeMap<KeyId, Signature>,
+}
+
+impl<T> Signed<T> {
+ pub fn fmap<U, F>(self, f: F) -> Signed<U>
+ where
+ F: FnOnce(T) -> U,
+ {
+ Signed {
+ signed: f(self.signed),
+ signatures: self.signatures,
+ }
+ }
+}
+
+impl<T, E> Signed<Result<T, E>> {
+ pub fn transpose(self) -> Result<Signed<T>, E> {
+ let Self { signed, signatures } = self;
+ signed.map(|signed| Signed { signed, signatures })
+ }
+}
+
+impl<T: HasPrev> Signed<T> {
+ pub fn ancestors<F>(&self, find_prev: F) -> impl Iterator<Item = io::Result<Self>>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Self>,
+ {
+ Ancestors {
+ prev: self.signed.prev().cloned(),
+ find_prev,
+ _marker: PhantomData,
+ }
+ }
+
+ pub fn has_ancestor<F>(&self, ancestor: &ContentHash, find_prev: F) -> io::Result<bool>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Self>,
+ {
+ match self.signed.prev() {
+ None => Ok(false),
+ Some(parent) if parent == ancestor => Ok(true),
+ Some(_) => {
+ for prev in self.ancestors(find_prev) {
+ match prev?.signed.prev() {
+ None => return Ok(false),
+ Some(parent) if parent == ancestor => return Ok(true),
+ _ => continue,
+ }
+ }
+
+ Ok(false)
+ },
+ }
+ }
+}
+
+impl Signed<Drop> {
+ pub fn verified<'a, F, G>(
+ self,
+ find_prev: F,
+ find_signer: G,
+ ) -> Result<drop::Verified, error::Verification>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Self>,
+ G: FnMut(&IdentityId) -> io::Result<KeySet<'a>>,
+ {
+ self.signed
+ .verified(&self.signatures, find_prev, find_signer)
+ }
+}
+
+impl Signed<Identity> {
+ pub fn verified<F>(self, find_prev: F) -> Result<identity::Verified, error::Verification>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Self>,
+ {
+ self.signed.verified(&self.signatures, find_prev)
+ }
+
+ pub fn verify<F>(&self, find_prev: F) -> Result<IdentityId, error::Verification>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Self>,
+ {
+ self.signed.verify(&self.signatures, find_prev)
+ }
+}
+
+impl<T> AsRef<T> for Signed<T> {
+ fn as_ref(&self) -> &T {
+ &self.signed
+ }
+}
+
+struct Ancestors<T, F> {
+ prev: Option<ContentHash>,
+ find_prev: F,
+ _marker: PhantomData<T>,
+}
+
+impl<T, F> Iterator for Ancestors<T, F>
+where
+ T: HasPrev,
+ F: FnMut(&ContentHash) -> io::Result<Signed<T>>,
+{
+ type Item = io::Result<Signed<T>>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let prev = self.prev.take()?;
+ (self.find_prev)(&prev)
+ .map(|parent| {
+ self.prev = parent.signed.prev().cloned();
+ Some(parent)
+ })
+ .transpose()
+ }
+}
+
+pub trait HasPrev {
+ fn prev(&self) -> Option<&ContentHash>;
+}
+
+impl HasPrev for Identity {
+ fn prev(&self) -> Option<&ContentHash> {
+ self.prev.as_ref()
+ }
+}
+
+impl HasPrev for Drop {
+ fn prev(&self) -> Option<&ContentHash> {
+ self.prev.as_ref()
+ }
+}
+
+#[derive(Clone)]
+pub struct Key<'a>(VerificationKey<'a>);
+
+impl Key<'_> {
+ pub fn id(&self) -> KeyId {
+ self.into()
+ }
+}
+
+impl fmt::Debug for Key<'_> {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_tuple("Key").field(&self.0.to_string()).finish()
+ }
+}
+
+impl<'a> From<VerificationKey<'a>> for Key<'a> {
+ fn from(vk: VerificationKey<'a>) -> Self {
+ Self(vk.without_comment())
+ }
+}
+
+impl signature::Verifier<Signature> for Key<'_> {
+ fn verify(&self, msg: &[u8], signature: &Signature) -> Result<(), signature::Error> {
+ let ssh = ssh::Signature::new(self.0.algorithm(), signature.as_ref())?;
+ self.0.verify(msg, &ssh)
+ }
+}
+
+impl serde::Serialize for Key<'_> {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&self.0.to_openssh().map_err(serde::ser::Error::custom)?)
+ }
+}
+
+impl<'de> serde::Deserialize<'de> for Key<'_> {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let s: &str = serde::Deserialize::deserialize(deserializer)?;
+ VerificationKey::from_openssh(s)
+ .map(Self)
+ .map_err(serde::de::Error::custom)
+ }
+}
+
+impl FromStr for Key<'_> {
+ type Err = ssh_key::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ VerificationKey::from_openssh(s).map(Self)
+ }
+}
+
+#[derive(Clone, Default)]
+pub struct KeySet<'a>(BTreeMap<KeyId, Key<'a>>);
+
+impl<'a> Deref for KeySet<'a> {
+ type Target = BTreeMap<KeyId, Key<'a>>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl<'a> DerefMut for KeySet<'a> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+}
+
+impl<'a> FromIterator<Key<'a>> for KeySet<'a> {
+ fn from_iter<T: IntoIterator<Item = Key<'a>>>(iter: T) -> Self {
+ let mut kv = BTreeMap::new();
+ for key in iter {
+ kv.insert(KeyId::from(&key), key);
+ }
+ Self(kv)
+ }
+}
+
+impl serde::Serialize for KeySet<'_> {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let mut seq = serializer.serialize_seq(Some(self.0.len()))?;
+ for key in self.0.values() {
+ seq.serialize_element(key)?;
+ }
+ seq.end()
+ }
+}
+
+impl<'de> serde::Deserialize<'de> for KeySet<'static> {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ struct Visitor;
+
+ impl<'de> serde::de::Visitor<'de> for Visitor {
+ type Value = KeySet<'static>;
+
+ fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str("a sequence of keys")
+ }
+
+ fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+ where
+ A: serde::de::SeqAccess<'de>,
+ {
+ let mut kv = BTreeMap::new();
+ while let Some(key) = seq.next_element()? {
+ kv.insert(KeyId::from(&key), key);
+ }
+
+ Ok(KeySet(kv))
+ }
+ }
+
+ deserializer.deserialize_seq(Visitor)
+ }
+}
+
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
+pub struct Signature(#[serde(with = "hex::serde")] Vec<u8>);
+
+impl From<ssh::Signature> for Signature {
+ fn from(sig: ssh::Signature) -> Self {
+ Self(sig.as_bytes().to_vec())
+ }
+}
+
+impl AsRef<[u8]> for Signature {
+ fn as_ref(&self) -> &[u8] {
+ self.0.as_ref()
+ }
+}
+
+impl signature::Signature for Signature {
+ fn from_bytes(bytes: &[u8]) -> Result<Self, signature::Error> {
+ Ok(Self(bytes.to_vec()))
+ }
+}
+
+pub struct Verified<T>(T);
+
+impl<T> Verified<T> {
+ pub fn into_inner(self) -> T {
+ self.0
+ }
+}
+
+impl<T> Deref for Verified<T> {
+ type Target = T;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
diff --git a/src/metadata/drop.rs b/src/metadata/drop.rs
new file mode 100644
index 0000000..d231712
--- /dev/null
+++ b/src/metadata/drop.rs
@@ -0,0 +1,274 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ borrow::Cow,
+ collections::{
+ BTreeMap,
+ BTreeSet,
+ HashMap,
+ },
+ io,
+ num::NonZeroUsize,
+};
+
+use log::warn;
+use sha2::{
+ Digest,
+ Sha512,
+};
+use signature::Verifier;
+
+use super::{
+ error,
+ Alternates,
+ ContentHash,
+ Custom,
+ DateTime,
+ IdentityId,
+ KeyId,
+ KeySet,
+ Metadata,
+ Mirrors,
+ Signature,
+ Signed,
+ SpecVersion,
+};
+use crate::{
+ git::Refname,
+ json::canonical,
+ str::Varchar,
+};
+
+#[derive(Clone, serde::Serialize, serde::Deserialize)]
+pub struct Roles {
+ pub root: Role,
+ pub snapshot: Role,
+ pub mirrors: Role,
+ pub branches: HashMap<Refname, Annotated>,
+}
+
+impl Roles {
+ pub(crate) fn ids(&self) -> BTreeSet<IdentityId> {
+ let Self {
+ root: Role { ids: root, .. },
+ snapshot: Role { ids: snapshot, .. },
+ mirrors: Role { ids: mirrors, .. },
+ branches,
+ } = self;
+
+ let mut ids = BTreeSet::new();
+ ids.extend(root);
+ ids.extend(snapshot);
+ ids.extend(mirrors);
+ ids.extend(branches.values().flat_map(|a| &a.role.ids));
+ ids
+ }
+}
+
+#[derive(Clone, serde::Serialize, serde::Deserialize)]
+pub struct Role {
+ pub ids: BTreeSet<IdentityId>,
+ pub threshold: NonZeroUsize,
+}
+
+pub type Description = Varchar<String, 128>;
+
+#[derive(Clone, serde::Serialize, serde::Deserialize)]
+pub struct Annotated {
+ #[serde(flatten)]
+ pub role: Role,
+ pub description: Description,
+}
+
+pub type Verified = super::Verified<Drop>;
+
+#[derive(Clone, serde::Serialize, serde::Deserialize)]
+pub struct Drop {
+ pub spec_version: SpecVersion,
+ #[serde(default = "Description::new")]
+ pub description: Description,
+ pub prev: Option<ContentHash>,
+ pub roles: Roles,
+ #[serde(default)]
+ pub custom: Custom,
+}
+
+impl Drop {
+ pub fn verified<'a, F, G>(
+ self,
+ signatures: &BTreeMap<KeyId, Signature>,
+ find_prev: F,
+ find_signer: G,
+ ) -> Result<Verified, error::Verification>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Signed<Self>>,
+ G: FnMut(&IdentityId) -> io::Result<KeySet<'a>>,
+ {
+ self.verify(signatures, find_prev, find_signer)?;
+ Ok(super::Verified(self))
+ }
+
+ pub fn verify<'a, F, G>(
+ &self,
+ signatures: &BTreeMap<KeyId, Signature>,
+ mut find_prev: F,
+ mut find_signer: G,
+ ) -> Result<(), error::Verification>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Signed<Self>>,
+ G: FnMut(&IdentityId) -> io::Result<KeySet<'a>>,
+ {
+ use error::Verification::*;
+
+ if !crate::SPEC_VERSION.is_compatible(&self.spec_version) {
+ return Err(IncompatibleSpecVersion);
+ }
+
+ let canonical = self.canonicalise()?;
+ let payload = Sha512::digest(&canonical);
+ verify::AuthorisedSigners::from_ids(&self.roles.root.ids, &mut find_signer)?
+ .verify_signatures(&payload, self.roles.root.threshold, signatures)?;
+ if let Some(prev) = self.prev.as_ref().map(&mut find_prev).transpose()? {
+ verify::AuthorisedSigners::from_ids(&prev.signed.roles.root.ids, &mut find_signer)?
+ .verify_signatures(&payload, prev.signed.roles.root.threshold, signatures)?;
+ return prev.signed.verify(&prev.signatures, find_prev, find_signer);
+ }
+
+ Ok(())
+ }
+
+ pub fn verify_mirrors<'a, F>(
+ &self,
+ mirrors: &Signed<Mirrors>,
+ find_signer: F,
+ ) -> Result<(), error::Verification>
+ where
+ F: FnMut(&IdentityId) -> io::Result<KeySet<'a>>,
+ {
+ use error::Verification::*;
+
+ if let Some(deadline) = &mirrors.signed.expires {
+ if deadline < &DateTime::now() {
+ return Err(Expired);
+ }
+ }
+ if !crate::SPEC_VERSION.is_compatible(&mirrors.signed.spec_version) {
+ return Err(IncompatibleSpecVersion);
+ }
+
+ let payload = Sha512::digest(mirrors.signed.canonicalise()?);
+ verify::AuthorisedSigners::from_ids(&self.roles.mirrors.ids, find_signer)?
+ .verify_signatures(&payload, self.roles.mirrors.threshold, &mirrors.signatures)
+ }
+
+ pub fn verify_alternates<'a, F>(
+ &self,
+ alt: &Signed<Alternates>,
+ find_signer: F,
+ ) -> Result<(), error::Verification>
+ where
+ F: FnMut(&IdentityId) -> io::Result<KeySet<'a>>,
+ {
+ use error::Verification::*;
+
+ if let Some(deadline) = &alt.signed.expires {
+ if deadline < &DateTime::now() {
+ return Err(Expired);
+ }
+ }
+ if !crate::SPEC_VERSION.is_compatible(&alt.signed.spec_version) {
+ return Err(IncompatibleSpecVersion);
+ }
+
+ let payload = Sha512::digest(alt.signed.canonicalise()?);
+ verify::AuthorisedSigners::from_ids(&self.roles.mirrors.ids, find_signer)?
+ .verify_signatures(&payload, self.roles.mirrors.threshold, &alt.signatures)
+ }
+
+ pub fn canonicalise(&self) -> Result<Vec<u8>, canonical::error::Canonicalise> {
+ canonical::to_vec(Metadata::drop(self))
+ }
+}
+
+impl From<Drop> for Cow<'static, Drop> {
+ fn from(d: Drop) -> Self {
+ Self::Owned(d)
+ }
+}
+
+impl<'a> From<&'a Drop> for Cow<'a, Drop> {
+ fn from(d: &'a Drop) -> Self {
+ Self::Borrowed(d)
+ }
+}
+
+mod verify {
+ use super::*;
+
+ pub struct AuthorisedSigners<'a, 'b>(BTreeMap<&'a IdentityId, KeySet<'b>>);
+
+ impl<'a, 'b> AuthorisedSigners<'a, 'b> {
+ pub fn from_ids<F>(
+ ids: &'a BTreeSet<IdentityId>,
+ mut find_signer: F,
+ ) -> Result<AuthorisedSigners<'a, 'b>, error::Verification>
+ where
+ F: FnMut(&IdentityId) -> io::Result<KeySet<'b>>,
+ {
+ let mut signers = BTreeMap::new();
+ for id in ids {
+ signers.insert(id, find_signer(id)?);
+ }
+ signers
+ .values()
+ .try_fold(BTreeSet::new(), |mut all_keys, keys| {
+ for key in keys.keys() {
+ if !all_keys.insert(key) {
+ return Err(error::Verification::DuplicateKey(*key));
+ }
+ }
+
+ Ok(all_keys)
+ })?;
+
+ Ok(Self(signers))
+ }
+
+ pub fn verify_signatures<'c, S>(
+ &mut self,
+ payload: &[u8],
+ threshold: NonZeroUsize,
+ signatures: S,
+ ) -> Result<(), error::Verification>
+ where
+ S: IntoIterator<Item = (&'c KeyId, &'c Signature)>,
+ {
+ use error::Verification::SignatureThreshold;
+
+ let mut need_signatures = threshold.get();
+ for (key_id, signature) in signatures {
+ if let Some(sig_id) = self.0.iter().find_map(|(id, keys)| {
+ #[allow(clippy::unnecessary_lazy_evaluations)]
+ keys.contains_key(key_id).then(|| *id)
+ }) {
+ let key = self.0.remove(sig_id).unwrap().remove(key_id).unwrap();
+ if key.verify(payload, signature).is_ok() {
+ need_signatures -= 1;
+ } else {
+ warn!("Bad signature by {key_id}");
+ }
+
+ if need_signatures == 0 {
+ break;
+ }
+ }
+ }
+ if need_signatures > 0 {
+ return Err(SignatureThreshold);
+ }
+
+ Ok(())
+ }
+ }
+}
diff --git a/src/metadata/error.rs b/src/metadata/error.rs
new file mode 100644
index 0000000..66173f9
--- /dev/null
+++ b/src/metadata/error.rs
@@ -0,0 +1,40 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::io;
+
+use thiserror::Error;
+
+use super::KeyId;
+use crate::json::canonical::error::Canonicalise;
+
+#[derive(Debug, Error)]
+pub enum SigId {
+ #[error("payload not at root revision")]
+ NotRoot,
+
+ #[error("invalid payload: canonicalisation failed")]
+ Canonical(#[from] Canonicalise),
+}
+
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum Verification {
+ #[error("incompatible spec version")]
+ IncompatibleSpecVersion,
+
+ #[error("canonicalisation failed")]
+ Canonicalise(#[from] Canonicalise),
+
+ #[error("required signature threshold not met")]
+ SignatureThreshold,
+
+ #[error("metadata past its expiry date")]
+ Expired,
+
+ #[error("duplicate key: key {0} appears in more than one identity")]
+ DuplicateKey(KeyId),
+
+ #[error(transparent)]
+ Io(#[from] io::Error),
+}
diff --git a/src/metadata/git.rs b/src/metadata/git.rs
new file mode 100644
index 0000000..1dde3da
--- /dev/null
+++ b/src/metadata/git.rs
@@ -0,0 +1,232 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ borrow::Cow,
+ io,
+};
+
+use anyhow::anyhow;
+
+use super::{
+ drop,
+ identity,
+ Alternates,
+ ContentHash,
+ Drop,
+ Identity,
+ IdentityId,
+ KeySet,
+ Metadata,
+ Mirrors,
+ Signed,
+};
+use crate::{
+ cmd,
+ git::if_not_found_none,
+ json,
+};
+
+pub const META_FILE_ALTERNATES: &str = "alternates.json";
+pub const META_FILE_DROP: &str = "drop.json";
+pub const META_FILE_ID: &str = "id.json";
+pub const META_FILE_MIRRORS: &str = "mirrors.json";
+
+pub mod error {
+ use thiserror::Error;
+
+ #[derive(Debug, Error)]
+ #[error("unexpected metadata type")]
+ pub struct TypeMismatch;
+
+ #[derive(Debug, Error)]
+ #[error("{file} not found in tree")]
+ pub struct FileNotFound {
+ pub file: &'static str,
+ }
+}
+
+pub struct GitMeta<T> {
+ pub hash: ContentHash,
+ pub signed: Signed<T>,
+}
+
+pub type GitIdentity = GitMeta<Identity>;
+pub type GitDrop = GitMeta<Drop>;
+pub type GitMirrors = GitMeta<Mirrors>;
+pub type GitAlternates = GitMeta<Alternates>;
+
+impl GitMeta<Drop> {
+ pub fn verified<'a, F, G>(
+ self,
+ find_prev: F,
+ find_signer: G,
+ ) -> Result<drop::Verified, super::error::Verification>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Signed<Drop>>,
+ G: FnMut(&IdentityId) -> io::Result<KeySet<'a>>,
+ {
+ self.signed.verified(find_prev, find_signer)
+ }
+}
+
+impl GitMeta<Identity> {
+ pub fn verified<F>(self, find_prev: F) -> Result<identity::Verified, super::error::Verification>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Signed<Identity>>,
+ {
+ self.signed.verified(find_prev)
+ }
+}
+
+pub struct FromSearchPath<'a, T> {
+ /// The repository (from the search path) the object was found in
+ pub repo: &'a git2::Repository,
+ pub meta: GitMeta<T>,
+}
+
+pub trait FromGit: Sized + Clone
+where
+ for<'a> Cow<'a, Self>: TryFrom<Metadata<'a>>,
+{
+ const METADATA_JSON: &'static str;
+
+ fn from_blob(blob: &git2::Blob) -> crate::Result<GitMeta<Self>> {
+ let hash = ContentHash::from(blob);
+ let signed = json::from_blob::<Signed<Metadata>>(blob)?
+ .fmap(Cow::<Self>::try_from)
+ .transpose()
+ .map_err(|_| error::TypeMismatch)?
+ .fmap(Cow::into_owned);
+
+ Ok(GitMeta { hash, signed })
+ }
+
+ fn from_tip<R: AsRef<str>>(
+ repo: &git2::Repository,
+ refname: R,
+ ) -> crate::Result<GitMeta<Self>> {
+ Self::from_reference(repo, &repo.find_reference(refname.as_ref())?)
+ }
+
+ fn from_reference(
+ repo: &git2::Repository,
+ reference: &git2::Reference,
+ ) -> crate::Result<GitMeta<Self>> {
+ Self::from_commit(repo, &reference.peel_to_commit()?)
+ }
+
+ fn from_commit(repo: &git2::Repository, commit: &git2::Commit) -> crate::Result<GitMeta<Self>> {
+ Self::from_tree(repo, &commit.tree()?)
+ }
+
+ fn from_tree(repo: &git2::Repository, tree: &git2::Tree) -> crate::Result<GitMeta<Self>> {
+ let entry = tree
+ .get_name(Self::METADATA_JSON)
+ .ok_or(error::FileNotFound {
+ file: Self::METADATA_JSON,
+ })?;
+ let blob = entry.to_object(repo)?.peel_to_blob()?;
+
+ Self::from_blob(&blob)
+ }
+
+ fn from_content_hash(
+ repo: &git2::Repository,
+ hash: &ContentHash,
+ ) -> crate::Result<GitMeta<Self>> {
+ let blob = repo.find_blob(hash.into())?;
+ Self::from_blob(&blob)
+ }
+
+ fn from_search_path<R: AsRef<str>>(
+ search_path: &[git2::Repository],
+ refname: R,
+ ) -> crate::Result<FromSearchPath<Self>> {
+ let (repo, reference) = find_ref_in_path(search_path, refname.as_ref())?
+ .ok_or_else(|| anyhow!("{} not found in search path", refname.as_ref()))?;
+ Self::from_reference(repo, &reference).map(|meta| FromSearchPath { repo, meta })
+ }
+}
+
+impl FromGit for Identity {
+ const METADATA_JSON: &'static str = META_FILE_ID;
+}
+
+impl FromGit for Drop {
+ const METADATA_JSON: &'static str = META_FILE_DROP;
+}
+
+impl FromGit for Mirrors {
+ const METADATA_JSON: &'static str = META_FILE_MIRRORS;
+}
+
+impl FromGit for Alternates {
+ const METADATA_JSON: &'static str = META_FILE_ALTERNATES;
+}
+
+pub fn find_parent<T>(
+ repo: &git2::Repository,
+) -> impl Fn(&ContentHash) -> io::Result<Signed<T>> + '_
+where
+ T: FromGit,
+ for<'a> Cow<'a, T>: TryFrom<Metadata<'a>>,
+{
+ |hash| {
+ T::from_content_hash(repo, hash)
+ .map_err(as_io)
+ .map(|meta| meta.signed)
+ }
+}
+
+pub fn find_parent_in_tree<'a, T>(
+ repo: &'a git2::Repository,
+ tree: &'a git2::Tree<'a>,
+) -> impl Fn(&ContentHash) -> io::Result<Signed<T>> + 'a
+where
+ T: FromGit,
+ for<'b> Cow<'b, T>: TryFrom<Metadata<'b>>,
+{
+ fn go<T>(
+ repo: &git2::Repository,
+ tree: &git2::Tree,
+ hash: &ContentHash,
+ ) -> crate::Result<Signed<T>>
+ where
+ T: FromGit,
+ for<'b> Cow<'b, T>: TryFrom<Metadata<'b>>,
+ {
+ let oid = git2::Oid::from(hash);
+ let blob = tree
+ .get_id(oid)
+ .ok_or_else(|| anyhow!("parent {} not found in tree {}", oid, tree.id()))?
+ .to_object(repo)?
+ .into_blob()
+ .map_err(|_| anyhow!("parent {} is not a file", oid))?;
+
+ T::from_blob(&blob).map(|meta| meta.signed)
+ }
+
+ move |hash| go(repo, tree, hash).map_err(as_io)
+}
+
+pub fn find_ref_in_path<'a>(
+ search_path: &'a [git2::Repository],
+ name: &str,
+) -> cmd::Result<Option<(&'a git2::Repository, git2::Reference<'a>)>> {
+ for repo in search_path {
+ let have_ref = if_not_found_none(repo.resolve_reference_from_short_name(name))?;
+ if let Some(r) = have_ref {
+ return Ok(Some((repo, r)));
+ }
+ }
+
+ Ok(None)
+}
+
+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/metadata/identity.rs b/src/metadata/identity.rs
new file mode 100644
index 0000000..8071e84
--- /dev/null
+++ b/src/metadata/identity.rs
@@ -0,0 +1,366 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ borrow::Cow,
+ collections::{
+ BTreeMap,
+ BTreeSet,
+ },
+ fmt,
+ io,
+ marker::PhantomData,
+ num::NonZeroUsize,
+ path::PathBuf,
+ str::FromStr,
+};
+
+use anyhow::{
+ anyhow,
+ ensure,
+};
+use hex::FromHex;
+use log::warn;
+use sha2::{
+ Digest,
+ Sha256,
+ Sha512,
+};
+use signature::Verifier;
+use url::Url;
+
+use super::{
+ error,
+ git::{
+ find_parent_in_tree,
+ FromGit,
+ META_FILE_ID,
+ },
+ Ancestors,
+ ContentHash,
+ Custom,
+ DateTime,
+ Key,
+ KeyId,
+ KeySet,
+ Metadata,
+ Signature,
+ Signed,
+ SpecVersion,
+};
+use crate::{
+ json::{
+ self,
+ canonical,
+ },
+ metadata::git::find_parent,
+};
+
+#[derive(
+ Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Hash, serde::Serialize, serde::Deserialize,
+)]
+pub struct IdentityId(#[serde(with = "hex::serde")] [u8; 32]);
+
+impl TryFrom<&Identity> for IdentityId {
+ type Error = error::SigId;
+
+ fn try_from(id: &Identity) -> Result<Self, Self::Error> {
+ if id.prev.is_some() {
+ return Err(error::SigId::NotRoot);
+ }
+ let digest = Sha256::digest(id.canonicalise()?);
+ Ok(Self(digest.into()))
+ }
+}
+
+impl fmt::Display for IdentityId {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ f.write_str(&hex::encode(self.0))
+ }
+}
+
+impl fmt::Debug for IdentityId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.to_string())
+ }
+}
+
+impl FromStr for IdentityId {
+ type Err = hex::FromHexError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ FromHex::from_hex(s).map(Self)
+ }
+}
+
+impl TryFrom<String> for IdentityId {
+ type Error = hex::FromHexError;
+
+ fn try_from(value: String) -> Result<Self, Self::Error> {
+ FromHex::from_hex(value).map(Self)
+ }
+}
+
+pub struct Verified {
+ id: IdentityId,
+ cur: Identity,
+}
+
+impl Verified {
+ pub fn id(&self) -> &IdentityId {
+ &self.id
+ }
+
+ pub fn identity(&self) -> &Identity {
+ &self.cur
+ }
+
+ pub fn into_parts(self) -> (IdentityId, Identity) {
+ (self.id, self.cur)
+ }
+
+ /// `true` if signature is valid over message for any of the signer's
+ /// _current_ set of keys
+ pub fn did_sign<T: AsRef<[u8]>>(&self, msg: T, sig: &Signature) -> bool {
+ self.cur
+ .keys
+ .values()
+ .any(|key| key.verify(msg.as_ref(), sig).is_ok())
+ }
+}
+
+impl AsRef<Identity> for Verified {
+ fn as_ref(&self) -> &Identity {
+ self.identity()
+ }
+}
+
+#[derive(Clone, serde::Serialize, serde::Deserialize)]
+pub struct Identity {
+ pub spec_version: SpecVersion,
+ pub prev: Option<ContentHash>,
+ pub keys: KeySet<'static>,
+ pub threshold: NonZeroUsize,
+ pub mirrors: BTreeSet<Url>,
+ pub expires: Option<DateTime>,
+ #[serde(default)]
+ pub custom: Custom,
+}
+
+impl Identity {
+ pub fn verified<F>(
+ self,
+ signatures: &BTreeMap<KeyId, Signature>,
+ find_prev: F,
+ ) -> Result<Verified, error::Verification>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Signed<Self>>,
+ {
+ let id = self.verify(signatures, find_prev)?;
+ Ok(Verified { id, cur: self })
+ }
+
+ pub fn verify<F>(
+ &self,
+ signatures: &BTreeMap<KeyId, Signature>,
+ find_prev: F,
+ ) -> Result<IdentityId, error::Verification>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Signed<Self>>,
+ {
+ use error::Verification::Expired;
+
+ if let Some(deadline) = &self.expires {
+ if deadline < &DateTime::now() {
+ return Err(Expired);
+ }
+ }
+ self.verify_tail(Cow::Borrowed(signatures), find_prev)
+ }
+
+ fn verify_tail<F>(
+ &self,
+ signatures: Cow<BTreeMap<KeyId, Signature>>,
+ mut find_prev: F,
+ ) -> Result<IdentityId, error::Verification>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Signed<Self>>,
+ {
+ use error::Verification::IncompatibleSpecVersion;
+
+ if !crate::SPEC_VERSION.is_compatible(&self.spec_version) {
+ return Err(IncompatibleSpecVersion);
+ }
+
+ let canonical = self.canonicalise()?;
+ let signed = Sha512::digest(&canonical);
+ verify_signatures(&signed, self.threshold, signatures.iter(), &self.keys)?;
+ if let Some(prev) = self.prev.as_ref().map(&mut find_prev).transpose()? {
+ verify_signatures(
+ &signed,
+ prev.signed.threshold,
+ signatures.iter(),
+ &prev.signed.keys,
+ )?;
+ return prev
+ .signed
+ .verify_tail(Cow::Owned(prev.signatures), find_prev);
+ }
+
+ Ok(IdentityId(Sha256::digest(canonical).into()))
+ }
+
+ pub fn canonicalise(&self) -> Result<Vec<u8>, canonical::error::Canonicalise> {
+ canonical::to_vec(Metadata::identity(self))
+ }
+
+ pub fn ancestors<F>(&self, find_prev: F) -> impl Iterator<Item = io::Result<Signed<Self>>>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Signed<Self>>,
+ {
+ Ancestors {
+ prev: self.prev.clone(),
+ find_prev,
+ _marker: PhantomData,
+ }
+ }
+
+ pub fn has_ancestor<F>(&self, ancestor: &ContentHash, find_prev: F) -> io::Result<bool>
+ where
+ F: FnMut(&ContentHash) -> io::Result<Signed<Self>>,
+ {
+ match &self.prev {
+ None => Ok(false),
+ Some(parent) if parent == ancestor => Ok(true),
+ Some(_) => {
+ for prev in self.ancestors(find_prev) {
+ match &prev?.signed.prev {
+ None => return Ok(false),
+ Some(parent) if parent == ancestor => return Ok(true),
+ _ => continue,
+ }
+ }
+
+ Ok(false)
+ },
+ }
+ }
+}
+
+impl From<Identity> for Cow<'static, Identity> {
+ fn from(s: Identity) -> Self {
+ Self::Owned(s)
+ }
+}
+
+impl<'a> From<&'a Identity> for Cow<'a, Identity> {
+ fn from(s: &'a Identity) -> Self {
+ Self::Borrowed(s)
+ }
+}
+
+fn verify_signatures<'a, S>(
+ payload: &[u8],
+ threshold: NonZeroUsize,
+ signatures: S,
+ keys: &BTreeMap<KeyId, Key>,
+) -> Result<(), error::Verification>
+where
+ S: IntoIterator<Item = (&'a KeyId, &'a Signature)>,
+{
+ use error::Verification::SignatureThreshold;
+
+ let mut need_signatures = threshold.get();
+ for (key_id, signature) in signatures {
+ if let Some(key) = keys.get(key_id) {
+ if key.verify(payload, signature).is_ok() {
+ need_signatures -= 1;
+ } else {
+ warn!("Bad signature by {key_id}");
+ }
+
+ if need_signatures == 0 {
+ break;
+ }
+ }
+ }
+ if need_signatures > 0 {
+ return Err(SignatureThreshold);
+ }
+
+ Ok(())
+}
+
+const FOLDED_HISTORY: &str = ".history";
+
+pub fn fold_to_tree<'a>(
+ repo: &'a git2::Repository,
+ tree: &mut git2::TreeBuilder<'a>,
+ Signed { signed, signatures }: Signed<Identity>,
+) -> crate::Result<()> {
+ use git2::FileMode::{
+ Blob,
+ Tree,
+ };
+
+ let meta = Signed {
+ signed: Metadata::from(&signed),
+ signatures,
+ };
+ tree.insert(META_FILE_ID, json::to_blob(repo, &meta)?, Blob.into())?;
+
+ let mut history = {
+ let existing = tree
+ .get(FOLDED_HISTORY)?
+ .map(|t| t.to_object(repo))
+ .transpose()?;
+ repo.treebuilder(existing.as_ref().and_then(git2::Object::as_tree))?
+ };
+ let mut parents = Vec::new();
+ for parent in signed.ancestors(find_parent(repo)) {
+ let meta = parent?.fmap(Metadata::from);
+ let blob = json::to_blob(repo, &meta)?;
+ parents.push(blob);
+ }
+ for (n, oid) in parents.into_iter().rev().enumerate() {
+ history.insert(&format!("{n}.json"), oid, Blob.into())?;
+ }
+ tree.insert(FOLDED_HISTORY, history.write()?, Tree.into())?;
+
+ Ok(())
+}
+
+pub fn find_in_tree(
+ repo: &git2::Repository,
+ root: &git2::Tree,
+ id: &IdentityId,
+) -> crate::Result<Verified> {
+ let (id_path, hist_path) = {
+ let base = PathBuf::from(id.to_string());
+ (base.join(META_FILE_ID), base.join(FOLDED_HISTORY))
+ };
+
+ let blob = root
+ .get_path(&id_path)?
+ .to_object(repo)?
+ .into_blob()
+ .map_err(|_| anyhow!("{} is not a file", id_path.display()))?;
+ let meta = Identity::from_blob(&blob)?.signed;
+ let hist = root
+ .get_path(&hist_path)?
+ .to_object(repo)?
+ .into_tree()
+ .map_err(|_| anyhow!("{} is not a directory", hist_path.display()))?;
+
+ let verified = meta
+ .signed
+ .verified(&meta.signatures, find_parent_in_tree(repo, &hist))?;
+ ensure!(
+ verified.id() == id,
+ "ids don't match after verification: expected {} found {}",
+ id,
+ verified.id()
+ );
+
+ Ok(verified)
+}
diff --git a/src/metadata/mirrors.rs b/src/metadata/mirrors.rs
new file mode 100644
index 0000000..9124dd3
--- /dev/null
+++ b/src/metadata/mirrors.rs
@@ -0,0 +1,95 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ borrow::Cow,
+ collections::BTreeSet,
+};
+
+use url::Url;
+
+use super::{
+ Custom,
+ DateTime,
+ Metadata,
+ SpecVersion,
+};
+use crate::{
+ json::canonical,
+ str::Varchar,
+};
+
+#[derive(Clone, serde::Serialize, serde::Deserialize)]
+pub struct Mirror {
+ pub url: Url,
+ #[serde(default)]
+ pub kind: Kind,
+ #[serde(default)]
+ pub custom: Custom,
+}
+
+#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Kind {
+ /// Can fetch bundles
+ Bundled,
+ /// Can fetch packs via git-protocol
+ #[default]
+ Packed,
+ /// Not serving bundles at all
+ Sparse,
+ /// Unknown kind
+ Unknown(Varchar<String, 16>),
+}
+
+#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
+pub struct Mirrors {
+ pub spec_version: SpecVersion,
+ pub mirrors: Vec<Mirror>,
+ pub expires: Option<DateTime>,
+}
+
+impl Mirrors {
+ pub fn canonicalise(&self) -> Result<Vec<u8>, canonical::error::Canonicalise> {
+ canonical::to_vec(Metadata::mirrors(self))
+ }
+}
+
+impl From<Mirrors> for Cow<'static, Mirrors> {
+ fn from(m: Mirrors) -> Self {
+ Self::Owned(m)
+ }
+}
+
+impl<'a> From<&'a Mirrors> for Cow<'a, Mirrors> {
+ fn from(m: &'a Mirrors) -> Self {
+ Self::Borrowed(m)
+ }
+}
+
+#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
+pub struct Alternates {
+ pub spec_version: SpecVersion,
+ pub alternates: BTreeSet<Url>,
+ #[serde(default)]
+ pub custom: Custom,
+ pub expires: Option<DateTime>,
+}
+
+impl Alternates {
+ pub fn canonicalise(&self) -> Result<Vec<u8>, canonical::error::Canonicalise> {
+ canonical::to_vec(Metadata::alternates(self))
+ }
+}
+
+impl From<Alternates> for Cow<'static, Alternates> {
+ fn from(a: Alternates) -> Self {
+ Self::Owned(a)
+ }
+}
+
+impl<'a> From<&'a Alternates> for Cow<'a, Alternates> {
+ fn from(a: &'a Alternates) -> Self {
+ Self::Borrowed(a)
+ }
+}
diff --git a/src/patches.rs b/src/patches.rs
new file mode 100644
index 0000000..8623e4e
--- /dev/null
+++ b/src/patches.rs
@@ -0,0 +1,212 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::{
+ fmt,
+ ops::Deref,
+};
+use std::{
+ io::BufRead,
+ str::FromStr,
+};
+
+use anyhow::{
+ anyhow,
+ bail,
+};
+
+use hex::FromHex;
+use once_cell::sync::Lazy;
+use sha2::{
+ digest::{
+ generic_array::GenericArray,
+ typenum::U32,
+ },
+ Digest,
+ Sha256,
+};
+
+use crate::{
+ git::Refname,
+ iter::IteratorExt,
+};
+
+mod traits;
+pub use traits::{
+ to_blob,
+ to_tree,
+ Seen,
+};
+use traits::{
+ write_sharded,
+ Blob,
+};
+
+mod bundle;
+pub use bundle::Bundle;
+
+mod error;
+pub use error::FromTree;
+
+pub mod iter;
+pub mod notes;
+
+pub mod record;
+pub use record::{
+ Record,
+ Signature,
+};
+
+mod state;
+pub use state::{
+ merge_notes,
+ unbundle,
+ unbundled_ref,
+ DropHead,
+};
+
+mod submit;
+pub use submit::{
+ AcceptArgs,
+ AcceptOptions,
+ Submission,
+ ALLOWED_REFS,
+ GLOB_HEADS,
+ GLOB_IT_BUNDLES,
+ GLOB_IT_IDS,
+ GLOB_IT_TOPICS,
+ GLOB_NOTES,
+ GLOB_TAGS,
+};
+
+pub const MAX_LEN_BUNDLE: usize = 5_000_000;
+
+pub const HTTP_HEADER_SIGNATURE: &str = "X-it-Signature";
+
+pub const REF_HEADS_PATCHES: &str = "refs/heads/patches";
+
+pub const REF_IT_BRANCHES: &str = "refs/it/branches";
+pub const REF_IT_BUNDLES: &str = "refs/it/bundles";
+pub const REF_IT_PATCHES: &str = "refs/it/patches";
+pub const REF_IT_SEEN: &str = "refs/it/seen";
+pub const REF_IT_TOPICS: &str = "refs/it/topics";
+
+pub const BLOB_HEADS: &str = "heads";
+pub const BLOB_META: &str = "record.json";
+
+pub static TOPIC_MERGES: Lazy<Topic> = Lazy::new(|| Topic::hashed("merges"));
+pub static TOPIC_SNAPSHOTS: Lazy<Topic> = Lazy::new(|| Topic::hashed("snapshots"));
+
+#[derive(Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
+pub struct Topic(#[serde(with = "hex::serde")] [u8; 32]);
+
+impl Topic {
+ const TRAILER_PREFIX: &str = "Re:";
+
+ pub fn hashed<T: AsRef<[u8]>>(v: T) -> Self {
+ Self(Sha256::digest(v).into())
+ }
+
+ pub fn from_commit(commit: &git2::Commit) -> crate::Result<Option<Self>> {
+ commit
+ .message_raw_bytes()
+ .lines()
+ .try_find_map(|line| -> crate::Result<Option<Topic>> {
+ let val = line?
+ .strip_prefix(Self::TRAILER_PREFIX)
+ .map(|v| Self::from_hex(v.trim()))
+ .transpose()?;
+ Ok(val)
+ })
+ }
+
+ pub fn as_trailer(&self) -> String {
+ format!("{} {}", Self::TRAILER_PREFIX, self)
+ }
+
+ pub fn from_refname(name: &str) -> crate::Result<Self> {
+ let last = name
+ .split('/')
+ .next_back()
+ .ok_or_else(|| anyhow!("invalid topic ref {name}"))?;
+ Ok(Self::from_hex(last)?)
+ }
+
+ pub fn as_refname(&self) -> Refname {
+ let name = format!("{}/{}", REF_IT_TOPICS, self);
+ Refname::try_from(name).unwrap()
+ }
+}
+
+impl FromHex for Topic {
+ type Error = hex::FromHexError;
+
+ fn from_hex<T: AsRef<[u8]>>(hex: T) -> Result<Self, Self::Error> {
+ <[u8; 32]>::from_hex(hex).map(Self)
+ }
+}
+
+impl FromStr for Topic {
+ type Err = <Self as FromHex>::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Self::from_hex(s)
+ }
+}
+
+impl fmt::Display for Topic {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&hex::encode(self.0))
+ }
+}
+
+impl fmt::Debug for Topic {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&hex::encode(self.0))
+ }
+}
+
+impl From<GenericArray<u8, U32>> for Topic {
+ fn from(a: GenericArray<u8, U32>) -> Self {
+ Self(a.into())
+ }
+}
+
+/// Maps a [`Refname`] to the [`REF_IT_BRANCHES`] namespace
+///
+/// The [`Refname`] must be a branch, ie. start with 'refs/heads/'.
+pub struct TrackingBranch(String);
+
+impl TrackingBranch {
+ pub fn master() -> Self {
+ Self([REF_IT_BRANCHES, "master"].join("/"))
+ }
+
+ pub fn main() -> Self {
+ Self([REF_IT_BRANCHES, "main"].join("/"))
+ }
+
+ pub fn into_refname(self) -> Refname {
+ Refname::try_from(self.0).unwrap()
+ }
+}
+
+impl Deref for TrackingBranch {
+ type Target = str;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl TryFrom<&Refname> for TrackingBranch {
+ type Error = crate::Error;
+
+ fn try_from(r: &Refname) -> Result<Self, Self::Error> {
+ match r.strip_prefix("refs/heads/") {
+ None => bail!("not a branch: {r}"),
+ Some("patches") => bail!("reserved name: {r}"),
+ Some(suf) => Ok(Self([REF_IT_BRANCHES, suf].join("/"))),
+ }
+ }
+}
diff --git a/src/patches/bundle.rs b/src/patches/bundle.rs
new file mode 100644
index 0000000..296b24a
--- /dev/null
+++ b/src/patches/bundle.rs
@@ -0,0 +1,344 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ fs::File,
+ io::{
+ self,
+ Read,
+ Seek,
+ SeekFrom,
+ },
+ iter,
+ path::{
+ Path,
+ PathBuf,
+ },
+};
+
+use anyhow::{
+ bail,
+ ensure,
+ Context,
+};
+use multipart::client::lazy::Multipart;
+use sha2::{
+ Digest,
+ Sha256,
+};
+use tempfile::NamedTempFile;
+use url::Url;
+
+use super::record::{
+ self,
+ Encryption,
+};
+use crate::{
+ bundle,
+ io::HashWriter,
+ keys::Signature,
+ Result,
+};
+
+pub struct Bundle {
+ pub(super) header: bundle::Header,
+ pub(super) path: PathBuf,
+ pub(super) info: bundle::Info,
+ pub(super) encryption: Option<Encryption>,
+ pack_start: u64,
+}
+
+impl Bundle {
+ pub fn create<P>(bundle_dir: P, repo: &git2::Repository, header: bundle::Header) -> Result<Self>
+ where
+ P: AsRef<Path>,
+ {
+ let bundle_dir = bundle_dir.as_ref();
+ std::fs::create_dir_all(bundle_dir)?;
+
+ let mut tmp = NamedTempFile::new_in(bundle_dir)?;
+ let info = bundle::create(&mut tmp, repo, &header)?;
+ let path = bundle_dir
+ .join(info.hash.to_string())
+ .with_extension(bundle::FILE_EXTENSION);
+ tmp.persist(&path)?;
+ let mut buf = Vec::new();
+ header.to_writer(&mut buf)?;
+ let pack_start = buf.len() as u64;
+
+ Ok(Self {
+ header,
+ path,
+ info,
+ encryption: None,
+ pack_start,
+ })
+ }
+
+ pub fn from_fetched(bundle: bundle::Fetched) -> Result<Self> {
+ let (path, info) = bundle.into_inner();
+ let (header, mut pack) = split(&path)?;
+ let pack_start = pack.offset;
+ let encryption = pack.encryption()?;
+
+ Ok(Self {
+ header,
+ path,
+ info,
+ encryption,
+ pack_start,
+ })
+ }
+
+ // TODO: defer computing the checksum until needed
+ pub fn from_stored<P>(bundle_dir: P, expect: bundle::Expect) -> Result<Self>
+ where
+ P: AsRef<Path>,
+ {
+ let path = bundle_dir
+ .as_ref()
+ .join(expect.hash.to_string())
+ .with_extension(bundle::FILE_EXTENSION);
+
+ let (header, mut pack) = split(&path)?;
+ let pack_start = pack.offset;
+ let encryption = pack.encryption()?;
+ drop(pack);
+ let mut file = File::open(&path)?;
+ let mut sha2 = Sha256::new();
+
+ let len = io::copy(&mut file, &mut sha2)?;
+ let hash = header.hash();
+ ensure!(expect.hash == &hash, "header hash mismatch");
+ let checksum = sha2.finalize().into();
+ if let Some(expect) = expect.checksum {
+ ensure!(expect == checksum, "claimed and actual hash differ");
+ }
+
+ let info = bundle::Info {
+ len,
+ hash,
+ checksum,
+ uris: vec![],
+ };
+
+ Ok(Self {
+ header,
+ path,
+ info,
+ encryption,
+ pack_start,
+ })
+ }
+
+ pub fn copy<R, P>(mut from: R, to: P) -> Result<Self>
+ where
+ R: Read,
+ P: AsRef<Path>,
+ {
+ std::fs::create_dir_all(&to)?;
+ let mut tmp = NamedTempFile::new_in(&to)?;
+ let mut out = HashWriter::new(Sha256::new(), &mut tmp);
+
+ let len = io::copy(&mut from, &mut out)?;
+ let checksum = out.hash().into();
+
+ let (header, mut pack) = split(tmp.path())?;
+ let hash = header.hash();
+ let pack_start = pack.offset;
+ let encryption = pack.encryption()?;
+
+ let info = bundle::Info {
+ len,
+ hash,
+ checksum,
+ uris: vec![],
+ };
+
+ let path = to
+ .as_ref()
+ .join(hash.to_string())
+ .with_extension(bundle::FILE_EXTENSION);
+ tmp.persist(&path)?;
+
+ Ok(Self {
+ header,
+ path,
+ info,
+ encryption,
+ pack_start,
+ })
+ }
+
+ pub fn encryption(&self) -> Option<Encryption> {
+ self.encryption
+ }
+
+ pub fn is_encrypted(&self) -> bool {
+ self.encryption.is_some()
+ }
+
+ pub fn reader(&self) -> Result<impl io::Read> {
+ Ok(File::open(&self.path)?)
+ }
+
+ pub fn header(&self) -> &bundle::Header {
+ &self.header
+ }
+
+ pub fn info(&self) -> &bundle::Info {
+ &self.info
+ }
+
+ pub fn packdata(&self) -> Result<Packdata> {
+ let bundle = File::open(&self.path)?;
+ Ok(Packdata {
+ offset: self.pack_start,
+ bundle,
+ })
+ }
+
+ pub fn default_location(&self) -> bundle::Location {
+ let uri = bundle::Uri::Relative(format!("/bundles/{}.bundle", self.info.hash));
+ let id = hex::encode(Sha256::digest(uri.as_str()));
+
+ bundle::Location {
+ id,
+ uri,
+ filter: None,
+ creation_token: None,
+ location: None,
+ }
+ }
+
+ pub fn bundle_list_path(&self) -> PathBuf {
+ self.path.with_extension(bundle::list::FILE_EXTENSION)
+ }
+
+ pub fn write_bundle_list<I>(&self, extra: I) -> Result<()>
+ where
+ I: IntoIterator<Item = bundle::Location>,
+ {
+ let mut blist = bundle::List::any();
+ blist.extend(
+ iter::once(self.default_location())
+ .chain(self.info.uris.iter().map(|url| {
+ let uri = bundle::Uri::Absolute(url.clone());
+ let id = hex::encode(Sha256::digest(uri.as_str()));
+
+ bundle::Location {
+ id,
+ uri,
+ filter: None,
+ creation_token: None,
+ location: None,
+ }
+ }))
+ .chain(extra),
+ );
+
+ let mut cfg = git2::Config::open(&self.bundle_list_path())?;
+ blist.to_config(&mut cfg)?;
+
+ Ok(())
+ }
+
+ pub fn sign<S>(&self, signer: &mut S) -> Result<Signature>
+ where
+ S: crate::keys::Signer,
+ {
+ Ok(signer.sign(record::Heads::from(&self.header).as_slice())?)
+ }
+
+ pub fn ipfs_add(&mut self, via: &Url) -> Result<Url> {
+ let name = format!("{}.{}", self.info.hash, bundle::FILE_EXTENSION);
+ let mut api = via.join("api/v0/add")?;
+ api.query_pairs_mut()
+ // FIXME: we may want this, but `rust-chunked-transfer` (used by
+ // `ureq`) doesn't know about trailers
+ // .append_pair("to-files", &name)
+ .append_pair("quiet", "true");
+ let mpart = Multipart::new()
+ .add_file(name, self.path.as_path())
+ .prepare()?;
+
+ #[derive(serde::Deserialize)]
+ struct Response {
+ #[serde(rename = "Hash")]
+ cid: String,
+ }
+
+ let Response { cid } = ureq::post(api.as_str())
+ .set(
+ "Content-Length",
+ &mpart
+ .content_len()
+ .expect("zero-size bundle file?")
+ .to_string(),
+ )
+ .set(
+ "Content-Type",
+ &format!("multipart/form-data; boundary={}", mpart.boundary()),
+ )
+ .send(mpart)
+ .context("posting to IPFS API")?
+ .into_json()
+ .context("parsing IPFS API response")?;
+
+ let url = Url::parse(&format!("ipfs://{cid}"))?;
+ self.info.uris.push(url.clone());
+
+ Ok(url)
+ }
+}
+
+impl From<Bundle> for bundle::Info {
+ fn from(Bundle { info, .. }: Bundle) -> Self {
+ info
+ }
+}
+
+fn split(bundle: &Path) -> Result<(bundle::Header, Packdata)> {
+ let mut bundle = File::open(bundle)?;
+ let header = bundle::Header::from_reader(&mut bundle)?;
+ let offset = bundle.stream_position()?;
+ let pack = Packdata { offset, bundle };
+ Ok((header, pack))
+}
+
+pub struct Packdata {
+ offset: u64,
+ bundle: File,
+}
+
+impl Packdata {
+ pub fn index(&mut self, odb: &git2::Odb) -> Result<()> {
+ self.bundle.seek(SeekFrom::Start(self.offset))?;
+
+ let mut pw = odb.packwriter()?;
+ io::copy(&mut self.bundle, &mut pw)?;
+ pw.commit()?;
+
+ Ok(())
+ }
+
+ pub fn encryption(&mut self) -> Result<Option<Encryption>> {
+ const PACK: &[u8] = b"PACK";
+ const AGE: &[u8] = b"age-encryption.org/v1";
+ const GPG: &[u8] = b"-----BEGIN PGP MESSAGE-----";
+
+ self.bundle.seek(SeekFrom::Start(self.offset))?;
+
+ let mut buf = [0; 32];
+ self.bundle.read_exact(&mut buf)?;
+ if buf.starts_with(PACK) {
+ Ok(None)
+ } else if buf.starts_with(AGE) {
+ Ok(Some(Encryption::Age))
+ } else if buf.starts_with(GPG) {
+ Ok(Some(Encryption::Gpg))
+ } else {
+ bail!("packdata does not appear to be in a known format")
+ }
+ }
+}
diff --git a/src/patches/error.rs b/src/patches/error.rs
new file mode 100644
index 0000000..a02ed94
--- /dev/null
+++ b/src/patches/error.rs
@@ -0,0 +1,29 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum FromTree {
+ #[error("'{name}' not found in tree")]
+ NotFound { name: &'static str },
+
+ #[error("expected '{name}' to be a blob, but found {kind:?}")]
+ TypeMismatch {
+ name: &'static str,
+ kind: Option<git2::ObjectType>,
+ },
+
+ #[error("max blob size {max} exceeded: {found}")]
+ BlobSize { max: usize, found: usize },
+
+ #[error("type conversion from byte slice to T failed")]
+ TypeConversion(#[source] crate::Error),
+
+ #[error("invalid signature")]
+ InvalidSignature(#[from] signature::Error),
+
+ #[error(transparent)]
+ Git(#[from] git2::Error),
+}
diff --git a/src/patches/iter.rs b/src/patches/iter.rs
new file mode 100644
index 0000000..6023247
--- /dev/null
+++ b/src/patches/iter.rs
@@ -0,0 +1,395 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ collections::BTreeSet,
+ rc::Rc,
+ str::FromStr,
+};
+
+use anyhow::anyhow;
+use time::{
+ OffsetDateTime,
+ UtcOffset,
+};
+
+use super::{
+ notes,
+ record::{
+ Heads,
+ Record,
+ },
+ Topic,
+ GLOB_IT_TOPICS,
+ TOPIC_MERGES,
+};
+use crate::{
+ git::{
+ self,
+ Refname,
+ EMPTY_TREE,
+ },
+ iter,
+ patches::REF_IT_BUNDLES,
+ Result,
+};
+
+pub mod dropped {
+ use super::*;
+ use crate::{
+ error,
+ patches::TOPIC_SNAPSHOTS,
+ };
+
+ pub fn topics<'a>(
+ repo: &'a git2::Repository,
+ drop_ref: &'a str,
+ ) -> impl Iterator<Item = Result<(Topic, git2::Oid)>> + 'a {
+ let topic = move |oid| -> Result<Option<(Topic, git2::Oid)>> {
+ let commit = repo.find_commit(oid)?;
+ Ok(Topic::from_commit(&commit)?.map(|topic| (topic, oid)))
+ };
+ let init = || {
+ let mut walk = repo.revwalk()?;
+ walk.push_ref(drop_ref)?;
+ Ok(walk.map(|i| i.map_err(Into::into)))
+ };
+
+ iter::Iter::new(init, Some).filter_map(move |oid| oid.and_then(topic).transpose())
+ }
+
+ pub fn topic<'a>(
+ repo: &'a git2::Repository,
+ drop_ref: &'a str,
+ topic: &'a Topic,
+ ) -> impl Iterator<Item = Result<git2::Oid>> + 'a {
+ topics(repo, drop_ref).filter_map(move |i| {
+ i.map(|(top, oid)| (&top == topic).then_some(oid))
+ .transpose()
+ })
+ }
+
+ #[allow(unused)]
+ pub fn merges<'a>(
+ repo: &'a git2::Repository,
+ drop_ref: &'a str,
+ ) -> impl Iterator<Item = Result<git2::Oid>> + 'a {
+ topic(repo, drop_ref, &TOPIC_MERGES)
+ }
+
+ #[allow(unused)]
+ pub fn snapshots<'a>(
+ repo: &'a git2::Repository,
+ drop_ref: &'a str,
+ ) -> impl Iterator<Item = Result<git2::Oid>> + 'a {
+ topic(repo, drop_ref, &TOPIC_SNAPSHOTS)
+ }
+
+ pub fn records<'a>(
+ repo: &'a git2::Repository,
+ drop_ref: &'a str,
+ ) -> impl Iterator<Item = Result<Record>> + 'a {
+ _records(repo, drop_ref, false)
+ }
+
+ pub fn records_rev<'a>(
+ repo: &'a git2::Repository,
+ drop_ref: &'a str,
+ ) -> impl Iterator<Item = Result<Record>> + 'a {
+ _records(repo, drop_ref, true)
+ }
+
+ fn _records<'a>(
+ repo: &'a git2::Repository,
+ drop_ref: &'a str,
+ rev: bool,
+ ) -> impl Iterator<Item = Result<Record>> + 'a {
+ let record = move |oid| -> Result<Option<Record>> {
+ let commit = repo.find_commit(oid)?;
+ match Record::from_commit(repo, &commit) {
+ Ok(r) => Ok(Some(r)),
+ Err(e) => match e.downcast_ref::<error::NotFound<&str, String>>() {
+ Some(error::NotFound { what: "topic", .. }) => Ok(None),
+ _ => Err(e),
+ },
+ }
+ };
+ let init = move || {
+ let mut walk = repo.revwalk()?;
+ walk.push_ref(drop_ref)?;
+ if rev {
+ walk.set_sorting(git2::Sort::REVERSE)?;
+ }
+ Ok(walk.map(|i| i.map_err(Into::into)))
+ };
+
+ iter::Iter::new(init, Some).filter_map(move |oid| oid.and_then(record).transpose())
+ }
+}
+
+pub mod unbundled {
+ use super::*;
+
+ #[allow(unused)]
+ pub fn topics(repo: &git2::Repository) -> impl Iterator<Item = Result<Topic>> + '_ {
+ iter::Iter::new(
+ move || {
+ let refs = repo.references_glob(GLOB_IT_TOPICS.glob())?;
+ Ok(git::ReferenceNames::new(refs, Topic::from_refname))
+ },
+ Some,
+ )
+ }
+
+ pub fn topics_with_subject(
+ repo: &git2::Repository,
+ ) -> impl Iterator<Item = Result<(Topic, String)>> + '_ {
+ let topic_and_subject = move |refname: &str| -> Result<(Topic, String)> {
+ let topic = Topic::from_refname(refname)?;
+ let subject = find_subject(repo, refname)?;
+ Ok((topic, subject))
+ };
+ iter::Iter::new(
+ move || {
+ let refs = repo.references_glob(GLOB_IT_TOPICS.glob())?;
+ Ok(git::ReferenceNames::new(refs, topic_and_subject))
+ },
+ Some,
+ )
+ }
+
+ // TODO: cache this somewhere
+ fn find_subject(repo: &git2::Repository, topic_ref: &str) -> Result<String> {
+ let mut walk = repo.revwalk()?;
+ walk.push_ref(topic_ref)?;
+ walk.simplify_first_parent()?;
+ walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
+ match walk.next() {
+ None => Ok(String::default()),
+ Some(oid) => {
+ let tree = repo.find_commit(oid?)?.tree()?;
+ let note = notes::Note::from_tree(repo, &tree)?;
+ let subj = match note {
+ notes::Note::Simple(n) => n
+ .checkpoint_kind()
+ .map(|k| {
+ match k {
+ notes::CheckpointKind::Merge => "Merges",
+ notes::CheckpointKind::Snapshot => "Snapshots",
+ }
+ .to_owned()
+ })
+ .unwrap_or_else(|| n.subject().unwrap_or_default().to_owned()),
+ _ => String::default(),
+ };
+
+ Ok(subj)
+ },
+ }
+ }
+}
+
+#[derive(Eq, PartialEq, serde::Serialize)]
+pub struct Subject {
+ pub name: String,
+ pub email: String,
+}
+
+impl TryFrom<git2::Signature<'_>> for Subject {
+ type Error = std::str::Utf8Error;
+
+ fn try_from(git: git2::Signature<'_>) -> std::result::Result<Self, Self::Error> {
+ let utf8 = |bs| std::str::from_utf8(bs).map(ToOwned::to_owned);
+
+ let name = utf8(git.name_bytes())?;
+ let email = utf8(git.email_bytes())?;
+
+ Ok(Self { name, email })
+ }
+}
+
+#[derive(serde::Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct NoteHeader {
+ #[serde(with = "git::serde::oid")]
+ pub id: git2::Oid,
+ pub author: Subject,
+ /// `Some` iff different from `author`
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub committer: Option<Subject>,
+ /// Committer time
+ #[serde(with = "time::serde::rfc3339")]
+ pub time: OffsetDateTime,
+ pub patch: Rc<PatchInfo>,
+ #[serde(
+ with = "git::serde::oid::option",
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub in_reply_to: Option<git2::Oid>,
+}
+
+#[derive(serde::Serialize)]
+pub struct PatchInfo {
+ pub id: Heads,
+ pub tips: BTreeSet<Refname>,
+}
+
+#[derive(serde::Serialize)]
+pub struct Note {
+ pub header: NoteHeader,
+ pub message: notes::Note,
+}
+
+pub fn topic<'a>(
+ repo: &'a git2::Repository,
+ topic: &'a Topic,
+) -> impl Iterator<Item = Result<Note>> + DoubleEndedIterator + 'a {
+ let init = move || {
+ let topic_ref = topic.as_refname();
+ let mut walk = repo.revwalk()?;
+ walk.push_ref(&topic_ref)?;
+ walk.set_sorting(git2::Sort::TOPOLOGICAL)?;
+
+ fn patch_id(c: &git2::Commit) -> Result<Option<Heads>> {
+ let parse = || Heads::try_from(c);
+ let is_merge = c.tree_id() == *EMPTY_TREE;
+ is_merge.then(parse).transpose()
+ }
+
+ fn patch_info(repo: &git2::Repository, id: Heads) -> Result<PatchInfo> {
+ let prefix = format!("{}/{}", REF_IT_BUNDLES, id);
+ let glob = format!("{prefix}/**");
+ let mut iter = repo.references_glob(&glob)?;
+ let tips = iter
+ .names()
+ .filter_map(|i| match i {
+ Err(e) => Some(Err(e.into())),
+ Ok(name)
+ if name
+ .strip_prefix(&prefix)
+ .expect("glob yields prefix")
+ .starts_with("/it/") =>
+ {
+ None
+ },
+ Ok(name) => Refname::from_str(name)
+ .map_err(Into::into)
+ .map(Some)
+ .transpose(),
+ })
+ .collect::<Result<_>>()?;
+
+ Ok(PatchInfo { id, tips })
+ }
+
+ let mut patches: Vec<Rc<PatchInfo>> = Vec::new();
+ let mut commits: Vec<(git2::Tree<'a>, NoteHeader)> = Vec::new();
+
+ if let Some(tip) = walk.next() {
+ // ensure tip is a merge
+ {
+ let tip = repo.find_commit(tip?)?;
+ let id = patch_id(&tip)?.ok_or_else(|| {
+ anyhow!("invalid topic '{topic_ref}': tip must be a merge commit")
+ })?;
+ let patch = patch_info(repo, id)?;
+ patches.push(Rc::new(patch));
+ }
+
+ for id in walk {
+ let commit = repo.find_commit(id?)?;
+ match patch_id(&commit)? {
+ Some(id) => {
+ let patch = patch_info(repo, id)?;
+ patches.push(Rc::new(patch))
+ },
+ None => {
+ let id = commit.id();
+ let (author, committer) = {
+ let a = commit.author();
+ let c = commit.committer();
+
+ if a.name_bytes() != c.name_bytes()
+ && a.email_bytes() != c.email_bytes()
+ {
+ let author = Subject::try_from(a)?;
+ let committer = Subject::try_from(c).map(Some)?;
+
+ (author, committer)
+ } else {
+ (Subject::try_from(a)?, None)
+ }
+ };
+ let time = {
+ let t = commit.time();
+ let ofs = UtcOffset::from_whole_seconds(t.offset_minutes() * 60)?;
+ OffsetDateTime::from_unix_timestamp(t.seconds())?.replace_offset(ofs)
+ };
+ let tree = commit.tree()?;
+ let patch = Rc::clone(&patches[patches.len() - 1]);
+ let in_reply_to = commit.parent_ids().next();
+
+ let header = NoteHeader {
+ id,
+ author,
+ committer,
+ time,
+ patch,
+ in_reply_to,
+ };
+
+ commits.push((tree, header));
+ },
+ }
+ }
+ }
+
+ Ok(commits.into_iter().map(move |(tree, header)| {
+ notes::Note::from_tree(repo, &tree).map(|message| Note { header, message })
+ }))
+ };
+
+ iter::Iter::new(init, Some)
+}
+
+pub mod topic {
+ use crate::git::if_not_found_none;
+
+ use super::*;
+
+ pub(crate) fn default_reply_to(
+ repo: &git2::Repository,
+ topic: &Topic,
+ ) -> Result<Option<git2::Oid>> {
+ let topic_ref = topic.as_refname();
+ if if_not_found_none(repo.refname_to_id(&topic_ref))?.is_none() {
+ return Ok(None);
+ }
+
+ let mut walk = repo.revwalk()?;
+ walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::REVERSE)?;
+ walk.push_ref(&topic_ref)?;
+
+ let first = walk
+ .next()
+ .expect("topic can't be empty, because {topic_ref} exists")?;
+ let mut last = first;
+ let mut seen = BTreeSet::<git2::Oid>::new();
+ for id in walk {
+ let id = id?;
+ let commit = repo.find_commit(id)?;
+ if commit.tree_id() != *EMPTY_TREE {
+ let first_parent = commit
+ .parent_ids()
+ .next()
+ .expect("commit {id} must have a parent");
+ if first_parent == first || !seen.contains(&first_parent) {
+ last = id;
+ }
+ seen.insert(id);
+ }
+ }
+
+ Ok(Some(last))
+ }
+}
diff --git a/src/patches/notes.rs b/src/patches/notes.rs
new file mode 100644
index 0000000..b85ca64
--- /dev/null
+++ b/src/patches/notes.rs
@@ -0,0 +1,181 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ cmp,
+ collections::BTreeMap,
+ convert::Infallible,
+ io,
+ ops::Range,
+};
+
+use super::{
+ error,
+ traits::{
+ Blob,
+ BlobData,
+ TreeData,
+ },
+};
+use crate::{
+ bundle::ObjectId,
+ git::Refname,
+};
+
+#[derive(serde::Serialize)]
+#[serde(untagged)]
+pub enum Note {
+ Simple(Simple),
+ Automerge(Automerge),
+}
+
+impl Note {
+ pub fn from_tree<'a>(repo: &'a git2::Repository, tree: &git2::Tree<'a>) -> crate::Result<Self> {
+ Blob::<Simple>::from_tree(repo, tree)
+ .map(|Blob { content, .. }| Self::Simple(content))
+ .or_else(|e| match e {
+ error::FromTree::NotFound { .. } => {
+ let Blob { content, .. } = Blob::<Automerge>::from_tree(repo, tree)?;
+ Ok(Self::Automerge(content))
+ },
+ x => Err(x.into()),
+ })
+ }
+}
+
+#[derive(serde::Serialize)]
+pub struct Automerge(Vec<u8>);
+
+impl BlobData for Automerge {
+ type Error = Infallible;
+
+ const MAX_BYTES: usize = 1_000_000;
+
+ fn from_blob(data: &[u8]) -> Result<Self, Self::Error> {
+ Ok(Self(data.to_vec()))
+ }
+
+ fn write_blob<W: io::Write>(&self, mut writer: W) -> io::Result<()> {
+ writer.write_all(&self.0)
+ }
+}
+
+impl TreeData for Automerge {
+ const BLOB_NAME: &'static str = "c";
+}
+
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
+#[serde(untagged)]
+pub enum Simple {
+ Known(Predef),
+ Unknown(serde_json::Map<String, serde_json::Value>),
+}
+
+impl Simple {
+ pub fn new(message: String) -> Self {
+ Self::basic(message)
+ }
+
+ pub fn basic(message: String) -> Self {
+ Self::Known(Predef::Basic { message })
+ }
+
+ pub fn checkpoint(
+ kind: CheckpointKind,
+ refs: BTreeMap<Refname, ObjectId>,
+ message: Option<String>,
+ ) -> Self {
+ Self::Known(Predef::Checkpoint {
+ kind,
+ refs,
+ message,
+ })
+ }
+
+ pub fn from_commit(repo: &git2::Repository, commit: &git2::Commit) -> crate::Result<Self> {
+ let tree = commit.tree()?;
+ let blob = Blob::from_tree(repo, &tree)?;
+
+ Ok(blob.content)
+ }
+
+ pub fn subject(&self) -> Option<&str> {
+ match self {
+ Self::Known(k) => k.subject(),
+ _ => None,
+ }
+ }
+
+ pub fn is_checkpoint(&self) -> bool {
+ matches!(self, Self::Known(Predef::Checkpoint { .. }))
+ }
+
+ pub fn checkpoint_kind(&self) -> Option<&CheckpointKind> {
+ match self {
+ Self::Known(Predef::Checkpoint { kind, .. }) => Some(kind),
+ _ => None,
+ }
+ }
+}
+
+impl BlobData for Simple {
+ type Error = serde_json::Error;
+
+ const MAX_BYTES: usize = 1_000_000;
+
+ fn from_blob(data: &[u8]) -> Result<Self, Self::Error> {
+ serde_json::from_slice(data)
+ }
+
+ fn write_blob<W: io::Write>(&self, writer: W) -> io::Result<()> {
+ serde_json::to_writer_pretty(writer, self).map_err(Into::into)
+ }
+}
+
+impl TreeData for Simple {
+ const BLOB_NAME: &'static str = "m";
+}
+
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
+#[serde(tag = "_type")]
+pub enum Predef {
+ #[serde(rename = "eagain.io/it/notes/basic")]
+ Basic { message: String },
+ #[serde(rename = "eagain.io/it/notes/code-comment")]
+ CodeComment { loc: SourceLoc, message: String },
+ #[serde(rename = "eagain.io/it/notes/checkpoint")]
+ Checkpoint {
+ kind: CheckpointKind,
+ refs: BTreeMap<Refname, ObjectId>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ message: Option<String>,
+ },
+}
+
+impl Predef {
+ pub fn subject(&self) -> Option<&str> {
+ let msg = match self {
+ Self::Basic { message } | Self::CodeComment { message, .. } => Some(message),
+ Self::Checkpoint { message, .. } => message.as_ref(),
+ }?;
+ let line = msg.lines().next()?;
+ let subj = &line[..cmp::min(72, line.len())];
+
+ (!subj.is_empty()).then_some(subj)
+ }
+}
+
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
+pub struct SourceLoc {
+ #[serde(with = "crate::git::serde::oid")]
+ pub file: git2::Oid,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub line: Option<Range<usize>>,
+}
+
+#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum CheckpointKind {
+ Merge,
+ Snapshot,
+}
diff --git a/src/patches/record.rs b/src/patches/record.rs
new file mode 100644
index 0000000..6a95973
--- /dev/null
+++ b/src/patches/record.rs
@@ -0,0 +1,472 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::ops::Deref;
+use std::{
+ collections::{
+ BTreeMap,
+ BTreeSet,
+ },
+ fmt,
+ io::{
+ self,
+ BufRead,
+ },
+ path::{
+ Path,
+ PathBuf,
+ },
+ str::FromStr,
+};
+
+use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+ Context,
+};
+
+use hex::{
+ FromHex,
+ ToHex,
+};
+
+use sha2::{
+ Digest,
+ Sha256,
+};
+use signature::{
+ Signature as _,
+ Verifier,
+};
+
+use super::{
+ traits::{
+ to_tree,
+ BlobData,
+ Foldable,
+ TreeData,
+ },
+ write_sharded,
+ Blob,
+ Bundle,
+ Topic,
+ BLOB_HEADS,
+ BLOB_META,
+ HTTP_HEADER_SIGNATURE,
+ TOPIC_MERGES,
+ TOPIC_SNAPSHOTS,
+};
+use crate::{
+ bundle,
+ error::NotFound,
+ git::{
+ self,
+ Refname,
+ },
+ iter::IteratorExt,
+ metadata::{
+ self,
+ identity,
+ ContentHash,
+ },
+};
+
+#[derive(Clone, Copy, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
+pub struct Heads(#[serde(with = "hex::serde")] [u8; 32]);
+
+impl Heads {
+ const TRAILER_PREFIX: &str = "Patch:";
+
+ pub fn from_commit(commit: &git2::Commit) -> crate::Result<Option<Self>> {
+ commit.message_raw_bytes().lines().try_find_map(|line| {
+ line?
+ .strip_prefix(Self::TRAILER_PREFIX)
+ .map(|s| Self::from_str(s.trim()).map_err(crate::Error::from))
+ .transpose()
+ })
+ }
+
+ pub fn as_trailer(&self) -> String {
+ format!("{} {}", Self::TRAILER_PREFIX, self)
+ }
+}
+
+impl Deref for Heads {
+ type Target = [u8; 32];
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl AsRef<[u8]> for Heads {
+ fn as_ref(&self) -> &[u8] {
+ &self.0
+ }
+}
+
+impl From<&bundle::Header> for Heads {
+ fn from(h: &bundle::Header) -> Self {
+ let tips = h.references.values().collect::<BTreeSet<_>>();
+ let mut hasher = Sha256::new();
+ for sha in tips {
+ hasher.update(sha.as_bytes());
+ }
+ Self(hasher.finalize().into())
+ }
+}
+
+impl TryFrom<&git2::Commit<'_>> for Heads {
+ type Error = crate::Error;
+
+ fn try_from(commit: &git2::Commit) -> Result<Self, Self::Error> {
+ Self::from_commit(commit)?.ok_or_else(|| {
+ anyhow!(NotFound {
+ what: "patch trailer",
+ whence: format!("commit {}", commit.id()),
+ })
+ })
+ }
+}
+
+impl FromStr for Heads {
+ type Err = hex::FromHexError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Self::from_hex(s)
+ }
+}
+
+impl FromHex for Heads {
+ type Error = hex::FromHexError;
+
+ fn from_hex<T: AsRef<[u8]>>(hex: T) -> Result<Self, Self::Error> {
+ <[u8; 32]>::from_hex(hex).map(Self)
+ }
+}
+
+impl fmt::Display for Heads {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&hex::encode(self.0))
+ }
+}
+
+impl fmt::Debug for Heads {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&hex::encode(self.0))
+ }
+}
+
+impl BlobData for Heads {
+ type Error = <[u8; 32] as FromHex>::Error;
+
+ const MAX_BYTES: usize = 64;
+
+ fn from_blob(data: &[u8]) -> Result<Self, Self::Error> {
+ Self::from_hex(data)
+ }
+
+ fn write_blob<W: io::Write>(&self, mut writer: W) -> io::Result<()> {
+ writer.write_all(self.encode_hex::<String>().as_bytes())
+ }
+}
+
+impl TreeData for Heads {
+ const BLOB_NAME: &'static str = BLOB_HEADS;
+}
+
+impl Foldable for Heads {
+ fn folded_name(&self) -> String {
+ self.encode_hex()
+ }
+}
+
+#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
+pub struct Signature {
+ pub signer: metadata::ContentHash,
+ pub signature: metadata::Signature,
+}
+
+impl From<Signature> for tiny_http::Header {
+ fn from(s: Signature) -> Self {
+ let value = format!(
+ "s1={}; s2={}; sd={}",
+ hex::encode(s.signer.sha1),
+ hex::encode(s.signer.sha2),
+ hex::encode(s.signature.as_ref())
+ );
+
+ Self::from_bytes(HTTP_HEADER_SIGNATURE.as_bytes(), value).unwrap()
+ }
+}
+
+impl TryFrom<&tiny_http::Header> for Signature {
+ type Error = crate::Error;
+
+ fn try_from(hdr: &tiny_http::Header) -> Result<Self, Self::Error> {
+ ensure!(
+ hdr.field.equiv(HTTP_HEADER_SIGNATURE),
+ "not a {HTTP_HEADER_SIGNATURE} header"
+ );
+
+ let mut sha1: Option<[u8; 20]> = None;
+ let mut sha2: Option<[u8; 32]> = None;
+ let mut signature = None;
+ for part in hdr.value.as_str().split(';') {
+ match part.trim().split_at(2) {
+ ("s1", val) => {
+ let bytes = <[u8; 20]>::from_hex(val)?;
+ sha1 = Some(bytes);
+ },
+ ("s2", val) => {
+ let bytes = <[u8; 32]>::from_hex(val)?;
+ sha2 = Some(bytes);
+ },
+ ("sd", val) => {
+ let bytes = hex::decode(val)?;
+ signature = Some(metadata::Signature::from_bytes(&bytes)?);
+ },
+
+ _ => continue,
+ }
+ }
+
+ let sha1 = sha1.ok_or_else(|| anyhow!("missing sha1 identity content hash"))?;
+ let sha2 = sha2.ok_or_else(|| anyhow!("missing sha2 identity content hash"))?;
+ let signature = signature.ok_or_else(|| anyhow!("missing signature bytes"))?;
+
+ Ok(Self {
+ signer: metadata::ContentHash { sha1, sha2 },
+ signature,
+ })
+ }
+}
+
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+pub struct Meta {
+ pub bundle: BundleInfo,
+ pub signature: Signature,
+}
+
+impl BlobData for Meta {
+ type Error = serde_json::Error;
+
+ const MAX_BYTES: usize = 100_000;
+
+ fn from_blob(data: &[u8]) -> Result<Self, Self::Error> {
+ serde_json::from_slice(data)
+ }
+
+ fn write_blob<W: io::Write>(&self, writer: W) -> io::Result<()> {
+ serde_json::to_writer_pretty(writer, self).map_err(Into::into)
+ }
+}
+
+impl TreeData for Meta {
+ const BLOB_NAME: &'static str = BLOB_META;
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Encryption {
+ Age,
+ Gpg,
+}
+
+impl Encryption {
+ pub fn as_str(&self) -> &str {
+ match self {
+ Self::Age => "age",
+ Self::Gpg => "gpg",
+ }
+ }
+}
+
+impl FromStr for Encryption {
+ type Err = serde_json::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ serde_json::from_str(s)
+ }
+}
+
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+pub struct BundleInfo {
+ #[serde(flatten)]
+ pub info: bundle::Info,
+ pub prerequisites: BTreeSet<bundle::ObjectId>,
+ pub references: BTreeMap<Refname, bundle::ObjectId>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub encryption: Option<Encryption>,
+}
+
+impl BundleInfo {
+ pub fn as_expect(&self) -> bundle::Expect {
+ bundle::Expect::from(&self.info)
+ }
+}
+
+impl From<&Bundle> for BundleInfo {
+ fn from(bundle: &Bundle) -> Self {
+ let (prerequisites, references) = {
+ let h = bundle.header();
+ (h.prerequisites.clone(), h.references.clone())
+ };
+ Self {
+ info: bundle.info().clone(),
+ prerequisites,
+ references,
+ encryption: bundle.encryption(),
+ }
+ }
+}
+
+/// Log record of a patch submission
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+pub struct Record {
+ pub topic: Topic,
+ pub heads: Heads,
+ pub meta: Meta,
+}
+
+impl Record {
+ pub fn from_commit<'a>(
+ repo: &'a git2::Repository,
+ commit: &git2::Commit<'a>,
+ ) -> crate::Result<Self> {
+ let topic = Topic::from_commit(commit)?.ok_or_else(|| crate::error::NotFound {
+ what: "topic",
+ whence: format!("message of commit {}", commit.id()),
+ })?;
+
+ let tree = commit.tree()?;
+
+ let mut heads: Option<Heads> = None;
+ let mut meta: Option<Meta> = None;
+
+ for entry in &tree {
+ match entry.name() {
+ Some(BLOB_HEADS) => {
+ heads = Some(Blob::<Heads>::from_entry(repo, entry)?.content);
+ },
+ Some(BLOB_META) => {
+ meta = Some(Blob::<Meta>::from_entry(repo, entry)?.content);
+ },
+
+ None | Some(_) => continue,
+ }
+ }
+
+ let whence = || format!("tree {}", tree.id());
+ let heads = heads.ok_or_else(|| crate::error::NotFound {
+ what: BLOB_HEADS,
+ whence: whence(),
+ })?;
+ let meta = meta.ok_or_else(|| crate::error::NotFound {
+ what: BLOB_META,
+ whence: whence(),
+ })?;
+
+ Ok(Self { topic, heads, meta })
+ }
+
+ pub fn commit<S>(
+ &self,
+ signer: &mut S,
+ repo: &git2::Repository,
+ ids: &git2::Tree,
+ parent: Option<&git2::Commit>,
+ seen: Option<&mut git2::TreeBuilder>,
+ ) -> crate::Result<git2::Oid>
+ where
+ S: crate::keys::Signer,
+ {
+ let tree = {
+ let mut tb = repo.treebuilder(parent.map(|p| p.tree()).transpose()?.as_ref())?;
+ tb.insert("ids", ids.id(), git2::FileMode::Tree.into())?;
+ to_tree(repo, &mut tb, &self.heads)?;
+ to_tree(repo, &mut tb, &self.meta)?;
+ repo.find_tree(tb.write()?)?
+ };
+ let oid = git::commit_signed(
+ signer,
+ repo,
+ self.topic.as_trailer(),
+ &tree,
+ &parent.into_iter().collect::<Vec<_>>(),
+ )?;
+
+ if let Some(seen) = seen {
+ write_sharded(
+ repo,
+ seen,
+ &self.heads,
+ tree.get_name(Heads::BLOB_NAME)
+ .expect("heads blob written above")
+ .id(),
+ )?;
+ }
+
+ Ok(oid)
+ }
+
+ pub fn signed_part(&self) -> [u8; 32] {
+ *self.heads
+ }
+
+ pub fn verify_signature<F>(&self, mut find_id: F) -> crate::Result<()>
+ where
+ F: FnMut(&ContentHash) -> crate::Result<identity::Verified>,
+ {
+ let signed_data = self.signed_part();
+ let addr = &self.meta.signature.signer;
+ let signature = &self.meta.signature.signature;
+ let id =
+ find_id(addr).with_context(|| format!("invalid or non-existent id at {:?}", addr))?;
+ for key in id.identity().keys.values() {
+ if key.verify(&signed_data, signature).is_ok() {
+ return Ok(());
+ }
+ }
+ bail!("signature key not in id at {:?}", addr);
+ }
+
+ pub fn bundle_info(&self) -> &BundleInfo {
+ &self.meta.bundle
+ }
+
+ pub fn bundle_hash(&self) -> &bundle::Hash {
+ &self.meta.bundle.info.hash
+ }
+
+ pub fn bundle_path(&self, prefix: &Path) -> PathBuf {
+ let mut p = prefix.join(self.bundle_hash().to_string());
+ p.set_extension(bundle::FILE_EXTENSION);
+ p
+ }
+
+ pub fn is_encrypted(&self) -> bool {
+ self.meta.bundle.encryption.is_some()
+ }
+
+ pub fn is_snapshot(&self) -> bool {
+ self.topic == *TOPIC_SNAPSHOTS
+ }
+
+ pub fn is_mergepoint(&self) -> bool {
+ self.topic == *TOPIC_MERGES
+ }
+
+ /// Remove traces of a record from the given tree
+ pub(crate) fn remove_from(tree: &mut git2::TreeBuilder) -> crate::Result<()> {
+ if tree.get(Heads::BLOB_NAME)?.is_some() {
+ tree.remove(Heads::BLOB_NAME)?;
+ }
+ if tree.get(Meta::BLOB_NAME)?.is_some() {
+ tree.remove(Meta::BLOB_NAME)?;
+ }
+
+ Ok(())
+ }
+}
diff --git a/src/patches/state.rs b/src/patches/state.rs
new file mode 100644
index 0000000..220971d
--- /dev/null
+++ b/src/patches/state.rs
@@ -0,0 +1,231 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ io,
+ ops::Range,
+};
+
+use anyhow::{
+ anyhow,
+ ensure,
+ Context,
+};
+use log::warn;
+
+use super::{
+ Record,
+ TrackingBranch,
+};
+use crate::{
+ git::{
+ self,
+ if_not_found_none,
+ refs::{
+ self,
+ LockedRef,
+ },
+ Refname,
+ },
+ keys::VerificationKey,
+ metadata::{
+ self,
+ git::FromGit,
+ identity,
+ },
+ Result,
+};
+
+/// Somewhat ad-hoc view of the tip of a drop
+pub struct DropHead<'a> {
+ pub tip: git2::Reference<'a>,
+ pub ids: git2::Tree<'a>,
+ pub meta: metadata::drop::Verified,
+}
+
+impl<'a> DropHead<'a> {
+ pub fn from_refname<S: AsRef<str>>(repo: &'a git2::Repository, name: S) -> crate::Result<Self> {
+ let tip = repo.find_reference(name.as_ref())?;
+ let root = tip.peel_to_tree()?;
+ let ids = root
+ .get_name("ids")
+ .ok_or_else(|| anyhow!("invalid drop: 'ids' tree not found"))?
+ .to_object(repo)?
+ .into_tree()
+ .map_err(|_| anyhow!("invalid drop: 'ids' tree is not a tree"))?;
+ let meta = metadata::Drop::from_tree(repo, &root)
+ .context("error loading drop metadata")?
+ .verified(metadata::git::find_parent(repo), |id| {
+ metadata::identity::find_in_tree(repo, &ids, id)
+ .map(|verified| verified.into_parts().1.keys)
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))
+ })?;
+
+ Ok(Self { tip, ids, meta })
+ }
+}
+
+pub fn unbundle(
+ odb: &git2::Odb,
+ tx: &mut refs::Transaction,
+ ref_prefix: &str,
+ record: &Record,
+) -> Result<Vec<(Refname, git2::Oid)>> {
+ let reflog = format!("it: storing head from {}", record.bundle_hash());
+
+ let mut updated = Vec::with_capacity(record.meta.bundle.references.len());
+ for (name, oid) in &record.meta.bundle.references {
+ let oid = git2::Oid::try_from(oid)?;
+ ensure!(odb.exists(oid), "ref not actually in bundle: {oid} {name}");
+
+ let by_heads = unbundled_ref(ref_prefix, record, name)?;
+ tx.lock_ref(by_heads.clone())?
+ .set_target(oid, reflog.clone());
+ updated.push((by_heads, oid));
+ }
+
+ Ok(updated)
+}
+
+pub fn unbundled_ref(prefix: &str, record: &Record, name: &Refname) -> Result<Refname> {
+ format!(
+ "{}/{}/{}",
+ prefix.trim_matches('/'),
+ record.heads,
+ name.trim_start_matches("refs/")
+ )
+ .try_into()
+ .map_err(Into::into)
+}
+
+pub fn merge_notes(
+ repo: &git2::Repository,
+ submitter: &identity::Verified,
+ topics_ref: &LockedRef,
+ record: &Record,
+) -> Result<()> {
+ let theirs: git2::Oid = record
+ .meta
+ .bundle
+ .references
+ .get(topics_ref.name())
+ .ok_or_else(|| anyhow!("invalid record: missing '{topics_ref}'"))?
+ .try_into()?;
+
+ let tree = git::empty_tree(repo)?;
+ let usr = repo.signature()?;
+ let theirs_commit = repo.find_commit(theirs)?;
+ match if_not_found_none(repo.find_reference(topics_ref.name()))? {
+ None => {
+ let msg = format!(
+ "Create topic from '{theirs}'\n\n{}",
+ record.heads.as_trailer()
+ );
+ let oid = repo.commit(None, &usr, &usr, &msg, &tree, &[&theirs_commit])?;
+ topics_ref.set_target(oid, "it: create topic");
+ },
+ Some(ours_ref) => {
+ let ours_commit = ours_ref.peel_to_commit()?;
+ let ours = ours_commit.id();
+
+ ensure!(ours != theirs, "illegal state: theirs equals ours ({ours})");
+
+ let base = repo
+ .merge_base(ours, theirs)
+ .with_context(|| format!("{topics_ref}: {theirs} diverges from {ours}"))?;
+ let theirs_commit = repo.find_commit(theirs)?;
+
+ verify_commit_range(repo, submitter, theirs_commit.id()..base)?;
+
+ let msg = format!(
+ "Merge '{theirs}' into {}\n\n{}",
+ record.topic,
+ record.heads.as_trailer()
+ );
+ let oid = repo.commit(
+ None,
+ &usr,
+ &usr,
+ &msg,
+ &tree,
+ &[&ours_commit, &theirs_commit],
+ )?;
+ let reflog = format!("it: auto-merge from {theirs}");
+ topics_ref.set_target(oid, reflog);
+ },
+ }
+
+ Ok(())
+}
+
+pub fn update_branches(
+ repo: &git2::Repository,
+ tx: &mut refs::Transaction,
+ submitter: &identity::Verified,
+ meta: &metadata::drop::Verified,
+ record: &Record,
+) -> Result<()> {
+ let branches = meta
+ .roles
+ .branches
+ .iter()
+ .filter_map(|(name, role)| role.role.ids.contains(submitter.id()).then_some(name));
+ for branch in branches {
+ let sandboxed = match TrackingBranch::try_from(branch) {
+ Ok(tracking) => tracking.into_refname(),
+ Err(e) => {
+ warn!("Skipping invalid branch {branch}: {e}");
+ continue;
+ },
+ };
+
+ if let Some(target) = record.meta.bundle.references.get(branch) {
+ let target = git2::Oid::try_from(target)?;
+ let locked = tx.lock_ref(sandboxed.clone())?;
+ let reflog = format!(
+ "it: update tip from {} by {}",
+ record.bundle_hash(),
+ submitter.id()
+ );
+ match if_not_found_none(repo.refname_to_id(&sandboxed))? {
+ Some(ours) => {
+ ensure!(
+ repo.graph_descendant_of(target, ours)?,
+ "checkpoint branch {branch} diverges from previously recorded tip {target}"
+ );
+ locked.set_target(target, reflog);
+ },
+ None => locked.set_target(target, reflog),
+ }
+
+ if repo.is_bare() {
+ tx.lock_ref(branch.clone())?
+ .set_symbolic_target(sandboxed, "it: symref auto-updated branch".to_owned());
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn verify_commit_range(
+ repo: &git2::Repository,
+ allowed: &identity::Verified,
+ Range { start, end }: Range<git2::Oid>,
+) -> Result<()> {
+ let mut walk = repo.revwalk()?;
+ walk.push(start)?;
+ walk.hide(end)?;
+ walk.simplify_first_parent()?;
+ walk.set_sorting(git2::Sort::TOPOLOGICAL)?;
+ for id in walk {
+ let pk = git::verify_commit_signature(repo, &id?)?;
+ let keyid = VerificationKey::from(pk).keyid();
+ ensure!(
+ allowed.identity().keys.contains_key(&keyid),
+ "good signature by unknown signer"
+ );
+ }
+
+ Ok(())
+}
diff --git a/src/patches/submit.rs b/src/patches/submit.rs
new file mode 100644
index 0000000..bca428b
--- /dev/null
+++ b/src/patches/submit.rs
@@ -0,0 +1,574 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ path::{
+ Path,
+ PathBuf,
+ },
+ str::FromStr,
+};
+
+use anyhow::{
+ anyhow,
+ bail,
+ ensure,
+ Context,
+};
+use globset::{
+ Glob,
+ GlobBuilder,
+ GlobSet,
+ GlobSetBuilder,
+};
+use log::info;
+use once_cell::sync::Lazy;
+use thiserror::Error;
+use tiny_http::Request;
+use url::Url;
+
+use super::{
+ bundle::Bundle,
+ record::{
+ self,
+ Heads,
+ Signature,
+ },
+ state,
+ Record,
+ Seen,
+ Topic,
+ HTTP_HEADER_SIGNATURE,
+ MAX_LEN_BUNDLE,
+ REF_IT_BUNDLES,
+ REF_IT_TOPICS,
+ TOPIC_MERGES,
+};
+use crate::{
+ bundle,
+ git::{
+ self,
+ if_not_found_none,
+ refs,
+ },
+ metadata::{
+ self,
+ git::{
+ FromGit,
+ GitMeta,
+ META_FILE_ID,
+ },
+ identity,
+ ContentHash,
+ Signed,
+ Verified,
+ },
+ Result,
+};
+
+pub static GLOB_HEADS: Lazy<Glob> = Lazy::new(|| Glob::new("refs/heads/**").unwrap());
+pub static GLOB_TAGS: Lazy<Glob> = Lazy::new(|| Glob::new("refs/tags/**").unwrap());
+pub static GLOB_NOTES: Lazy<Glob> = Lazy::new(|| Glob::new("refs/notes/**").unwrap());
+
+pub static GLOB_IT_TOPICS: Lazy<Glob> = Lazy::new(|| {
+ GlobBuilder::new(&format!("{}/*", REF_IT_TOPICS))
+ .literal_separator(true)
+ .build()
+ .unwrap()
+});
+pub static GLOB_IT_IDS: Lazy<Glob> = Lazy::new(|| {
+ GlobBuilder::new("refs/it/ids/*")
+ .literal_separator(true)
+ .build()
+ .unwrap()
+});
+pub static GLOB_IT_BUNDLES: Lazy<Glob> =
+ Lazy::new(|| Glob::new(&format!("{}/**", REF_IT_BUNDLES)).unwrap());
+
+pub static ALLOWED_REFS: Lazy<GlobSet> = Lazy::new(|| {
+ GlobSetBuilder::new()
+ .add(GLOB_HEADS.clone())
+ .add(GLOB_TAGS.clone())
+ .add(GLOB_NOTES.clone())
+ .add(GLOB_IT_TOPICS.clone())
+ .add(GLOB_IT_IDS.clone())
+ .build()
+ .unwrap()
+});
+
+pub struct AcceptArgs<'a, S> {
+ /// The prefix under which to store the refs contained in the bundle
+ pub unbundle_prefix: &'a str,
+ /// The refname of the drop history
+ pub drop_ref: &'a str,
+ /// The refname anchoring the seen objects tree
+ pub seen_ref: &'a str,
+ /// The repo to operate on
+ pub repo: &'a git2::Repository,
+ /// The signer for the drop history
+ pub signer: &'a mut S,
+ /// IPFS API address
+ pub ipfs_api: Option<&'a Url>,
+ /// Options
+ pub options: AcceptOptions,
+}
+
+pub struct AcceptOptions {
+ /// Allow bundles to convey "fat" packs, ie. packs which do not have any
+ /// prerequisites
+ ///
+ /// Default: false
+ pub allow_fat_pack: bool,
+ /// Allow encrypted bundles
+ ///
+ /// Default: false
+ pub allow_encrypted: bool,
+ /// Allowed ref name patterns
+ ///
+ /// Default:
+ ///
+ /// - refs/heads/**
+ /// - refs/tags/**
+ /// - refs/notes/**
+ /// - refs/it/topics/*
+ /// - refs/it/ids/*
+ pub allowed_refs: GlobSet,
+ /// Maximum number of branches the bundle is allowed to carry
+ ///
+ /// A branch is a ref which starts with `refs/heads/`.
+ ///
+ /// Default: 1
+ pub max_branches: usize,
+ /// Maximum number of tags the bundle is allowed to carry
+ ///
+ /// A tag is a ref which starts with `refs/tags/`.
+ ///
+ /// Default: 1
+ pub max_tags: usize,
+ /// Maximum number of git notes refs the bundle is allowed to carry
+ ///
+ /// A notes ref is a ref which starts with `refs/notes/`.
+ ///
+ /// Default: 1
+ pub max_notes: usize,
+ /// Maximum number of refs in the bundle, considering all refs
+ ///
+ /// Default: 10,
+ pub max_refs: usize,
+ /// Maximum number of commits a bundle ref can have
+ ///
+ /// Default: 20
+ pub max_commits: usize,
+}
+
+impl Default for AcceptOptions {
+ fn default() -> Self {
+ Self {
+ allow_fat_pack: false,
+ allow_encrypted: false,
+ allowed_refs: ALLOWED_REFS.clone(),
+ max_branches: 1,
+ max_tags: 1,
+ max_notes: 1,
+ max_refs: 10,
+ max_commits: 20,
+ }
+ }
+}
+
+pub struct Submission {
+ pub signature: Signature,
+ pub bundle: Bundle,
+}
+
+impl Submission {
+ pub fn from_http<P>(bundle_dir: P, req: &mut Request) -> Result<Self>
+ where
+ P: AsRef<Path>,
+ {
+ let len = req
+ .body_length()
+ .ok_or_else(|| anyhow!("chunked body not permitted"))?;
+ ensure!(
+ len <= MAX_LEN_BUNDLE,
+ "submitted patch bundle exceeds {MAX_LEN_BUNDLE}",
+ );
+
+ let mut signature = None;
+
+ for hdr in req.headers() {
+ if hdr.field.equiv(HTTP_HEADER_SIGNATURE) {
+ let sig = Signature::try_from(hdr)?;
+ signature = Some(sig);
+ break;
+ }
+ }
+
+ #[derive(Debug, Error)]
+ #[error("missing header {0}")]
+ struct Missing(&'static str);
+
+ let signature = signature.ok_or(Missing(HTTP_HEADER_SIGNATURE))?;
+ let bundle = Bundle::copy(req.as_reader(), bundle_dir)?;
+
+ Ok(Self { signature, bundle })
+ }
+
+ pub fn submit(self, mut base_url: Url) -> Result<Record> {
+ base_url
+ .path_segments_mut()
+ .map_err(|()| anyhow!("invalid url"))?
+ .push("patches");
+ let tiny_http::Header {
+ field: sig_hdr,
+ value: sig,
+ } = self.signature.into();
+ let req = ureq::request_url("POST", &base_url)
+ .set("Content-Length", &self.bundle.info.len.to_string())
+ .set(sig_hdr.as_str().as_str(), sig.as_str());
+ let res = req.send(self.bundle.reader()?)?;
+
+ Ok(res.into_json()?)
+ }
+
+ pub fn try_accept<S>(
+ &mut self,
+ AcceptArgs {
+ unbundle_prefix,
+ drop_ref,
+ seen_ref,
+ repo,
+ signer,
+ ipfs_api,
+ options,
+ }: AcceptArgs<S>,
+ ) -> Result<Record>
+ where
+ S: crate::keys::Signer,
+ {
+ ensure!(
+ unbundle_prefix.starts_with("refs/"),
+ "prefix must start with 'refs/'"
+ );
+ ensure!(
+ !self.bundle.is_encrypted() || options.allow_encrypted,
+ "encrypted bundle rejected"
+ );
+
+ let header = &self.bundle.header;
+
+ ensure!(
+ matches!(header.object_format, bundle::ObjectFormat::Sha1),
+ "object-format {} not (yet) supported",
+ header.object_format
+ );
+ ensure!(
+ !header.prerequisites.is_empty() || options.allow_fat_pack,
+ "thin pack required"
+ );
+ ensure!(
+ header.references.len() <= options.max_refs,
+ "max number of refs exceeded"
+ );
+ let topic = {
+ let mut topic: Option<Topic> = None;
+
+ let mut heads = 0;
+ let mut tags = 0;
+ let mut notes = 0;
+ static GIT_IT: Lazy<GlobSet> = Lazy::new(|| {
+ GlobSetBuilder::new()
+ .add(GLOB_HEADS.clone())
+ .add(GLOB_TAGS.clone())
+ .add(GLOB_NOTES.clone())
+ .add(GLOB_IT_TOPICS.clone())
+ .build()
+ .unwrap()
+ });
+ let mut matches = Vec::with_capacity(1);
+ for r in header.references.keys() {
+ let cand = globset::Candidate::new(r);
+ ensure!(
+ options.allowed_refs.is_match_candidate(&cand),
+ "unconventional ref rejected: {r}"
+ );
+ GIT_IT.matches_candidate_into(&cand, &mut matches);
+ match &matches[..] {
+ [] => {},
+ [0] => heads += 1,
+ [1] => tags += 1,
+ [2] => notes += 1,
+ [3] => {
+ ensure!(topic.is_none(), "more than one topic");
+ match r.split('/').next_back() {
+ None => bail!("invalid notes '{r}': missing topic"),
+ Some(s) => {
+ let t = Topic::from_str(s).context("invalid topic")?;
+ topic = Some(t);
+ },
+ }
+ },
+ x => unreachable!("impossible match: {x:?}"),
+ }
+ }
+ ensure!(
+ heads <= options.max_branches,
+ "max number of git branches exceeded"
+ );
+ ensure!(tags <= options.max_tags, "max number of git tags exceeded");
+ ensure!(
+ notes <= options.max_notes,
+ "max number of git notes exceeded"
+ );
+
+ topic.ok_or_else(|| anyhow!("missing '{}'", GLOB_IT_TOPICS.glob()))?
+ };
+ let heads = Heads::from(header);
+
+ let mut tx = refs::Transaction::new(repo)?;
+ let seen_ref = tx.lock_ref(seen_ref.parse()?)?;
+ let seen_tree = match if_not_found_none(repo.find_reference(seen_ref.name()))? {
+ Some(seen) => seen.peel_to_tree()?,
+ None => git::empty_tree(repo)?,
+ };
+ ensure!(!heads.in_tree(&seen_tree)?, "submission already exists");
+
+ // In a bare drop, indexing the pack is enough to detect missing
+ // prerequisites (ie. delta bases). Otherwise, or if the bundle is
+ // encrypted, we need to look for merge bases from the previously
+ // accepted patches.
+ if !repo.is_bare() || self.bundle.is_encrypted() {
+ let mut prereqs = header
+ .prerequisites
+ .iter()
+ .map(git2::Oid::try_from)
+ .collect::<std::result::Result<Vec<_>, _>>()?;
+
+ for r in repo.references_glob(GLOB_IT_BUNDLES.glob())? {
+ let commit = r?.peel_to_commit()?.id();
+ for (i, id) in prereqs.clone().into_iter().enumerate() {
+ if if_not_found_none(repo.merge_base(commit, id))?.is_some() {
+ prereqs.swap_remove(i);
+ }
+ }
+ if prereqs.is_empty() {
+ break;
+ }
+ }
+
+ ensure!(
+ prereqs.is_empty(),
+ "prerequisite commits not found, try checkpointing a branch or \
+ base the patch on a previous one: {}",
+ prereqs
+ .iter()
+ .map(ToString::to_string)
+ .collect::<Vec<_>>()
+ .join(", ")
+ );
+ }
+
+ let odb = repo.odb()?;
+ if !self.bundle.is_encrypted() {
+ let mut pack = self.bundle.packdata()?;
+ pack.index(&odb)?;
+
+ let prereqs = header
+ .prerequisites
+ .iter()
+ .map(git2::Oid::try_from)
+ .collect::<std::result::Result<Vec<_>, _>>()?;
+ let mut walk = repo.revwalk()?;
+ for (name, oid) in &header.references {
+ walk.push(oid.try_into()?)?;
+ for hide in &prereqs {
+ walk.hide(*hide)?;
+ }
+ let mut cnt = 0;
+ for x in &mut walk {
+ let _ = x?;
+ cnt += 1;
+ ensure!(
+ cnt <= options.max_commits,
+ "{name} exceeds configured max number of commits ({})",
+ options.max_commits
+ );
+ }
+ walk.reset()?;
+ }
+ }
+
+ if let Some(url) = ipfs_api {
+ let ipfs = self.bundle.ipfs_add(url)?;
+ info!("Published bundle to IPFS as {ipfs}");
+ }
+
+ let record = Record {
+ topic,
+ heads,
+ meta: record::Meta {
+ bundle: record::BundleInfo::from(&self.bundle),
+ signature: self.signature.clone(),
+ },
+ };
+
+ let drop_ref = tx.lock_ref(drop_ref.parse()?)?;
+ let mut drop = state::DropHead::from_refname(repo, drop_ref.name())?;
+ ensure!(
+ drop.meta.roles.snapshot.threshold.get() == 1,
+ "threshold signatures for drop snapshots not yet supported"
+ );
+ ensure!(
+ is_signer_eligible(signer, repo, &drop.ids, &drop.meta)?,
+ "supplied signer does not have the 'snapshot' role needed to record patches"
+ );
+
+ let submitter = {
+ let mut id = Identity::find(repo, &drop.ids, &self.signature.signer)?;
+ id.verify_signature(&record.signed_part(), &self.signature)?;
+ if let Some(updated) = id.update(repo, &drop.ids)? {
+ drop.ids = updated;
+ }
+ id.verified
+ };
+
+ let mut seen = repo.treebuilder(Some(&seen_tree))?;
+ let new_head = record.commit(
+ signer,
+ repo,
+ &drop.ids,
+ Some(&drop.tip.peel_to_commit()?),
+ Some(&mut seen),
+ )?;
+ drop_ref.set_target(new_head, format!("commit: {}", record.topic));
+ seen_ref.set_target(seen.write()?, format!("it: update to record {}", new_head));
+
+ if !self.bundle.is_encrypted() {
+ state::unbundle(&odb, &mut tx, unbundle_prefix, &record)?;
+ let topic_ref = tx.lock_ref(record.topic.as_refname())?;
+ state::merge_notes(repo, &submitter, &topic_ref, &record)?;
+ if record.topic == *TOPIC_MERGES {
+ state::update_branches(repo, &mut tx, &submitter, &drop.meta, &record)?;
+ }
+ }
+
+ tx.commit()?;
+
+ Ok(record)
+ }
+}
+
+fn is_signer_eligible<S>(
+ signer: &S,
+ repo: &git2::Repository,
+ ids: &git2::Tree,
+ meta: &Verified<metadata::Drop>,
+) -> Result<bool>
+where
+ S: crate::keys::Signer,
+{
+ let signer_id = metadata::KeyId::from(signer.ident());
+ for id in &meta.roles.snapshot.ids {
+ let s = metadata::identity::find_in_tree(repo, ids, id)?;
+ if s.identity().keys.contains_key(&signer_id) {
+ return Ok(true);
+ }
+ }
+
+ Ok(false)
+}
+
+struct Identity {
+ verified: identity::Verified,
+ to_update: Option<Signed<metadata::Identity>>,
+}
+
+impl Identity {
+ fn find(repo: &git2::Repository, ids: &git2::Tree, hash: &ContentHash) -> Result<Self> {
+ let find_parent = metadata::git::find_parent(repo);
+
+ let (theirs_hash, theirs_signed, theirs) = metadata::Identity::from_content_hash(
+ repo, hash,
+ )
+ .and_then(|GitMeta { hash, signed }| {
+ let signed_dup = signed.clone();
+ let verified = signed.verified(&find_parent)?;
+ Ok((hash, signed_dup, verified))
+ })?;
+
+ let tree_path = PathBuf::from(theirs.id().to_string()).join(META_FILE_ID);
+ let newer = match if_not_found_none(ids.get_path(&tree_path))? {
+ None => Self {
+ verified: theirs,
+ to_update: Some(theirs_signed),
+ },
+ Some(in_tree) if theirs_hash == in_tree.id() => Self {
+ verified: theirs,
+ to_update: None,
+ },
+ Some(in_tree) => {
+ let (ours_hash, ours) = metadata::Identity::from_blob(
+ &repo.find_blob(in_tree.id())?,
+ )
+ .and_then(|GitMeta { hash, signed }| {
+ let ours = signed.verified(&find_parent)?;
+ Ok((hash, ours))
+ })?;
+
+ if ours.identity().has_ancestor(&theirs_hash, &find_parent)? {
+ Self {
+ verified: ours,
+ to_update: None,
+ }
+ } else if theirs.identity().has_ancestor(&ours_hash, &find_parent)? {
+ Self {
+ verified: theirs,
+ to_update: Some(theirs_signed),
+ }
+ } else {
+ bail!(
+ "provided signer id at {} diverges from known id at {}",
+ theirs_hash,
+ ours_hash,
+ );
+ }
+ },
+ };
+
+ Ok(newer)
+ }
+
+ fn verify_signature(&self, msg: &[u8], sig: &Signature) -> Result<()> {
+ ensure!(
+ self.verified.did_sign(msg, &sig.signature),
+ "signature not valid for current keys in id {}, provided signer at {}",
+ self.verified.id(),
+ sig.signer
+ );
+ Ok(())
+ }
+
+ fn update<'a>(
+ &mut self,
+ repo: &'a git2::Repository,
+ root: &git2::Tree,
+ ) -> Result<Option<git2::Tree<'a>>> {
+ if let Some(meta) = self.to_update.take() {
+ let mut new_root = repo.treebuilder(Some(root))?;
+ let mut id_tree = repo.treebuilder(None)?;
+ metadata::identity::fold_to_tree(repo, &mut id_tree, meta)?;
+ new_root.insert(
+ self.verified.id().to_string().as_str(),
+ id_tree.write()?,
+ git2::FileMode::Tree.into(),
+ )?;
+
+ let oid = new_root.write()?;
+ let tree = repo.find_tree(oid).map(Some)?;
+
+ return Ok(tree);
+ }
+
+ Ok(None)
+ }
+}
diff --git a/src/patches/traits.rs b/src/patches/traits.rs
new file mode 100644
index 0000000..ef9ae61
--- /dev/null
+++ b/src/patches/traits.rs
@@ -0,0 +1,165 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ io,
+ path::{
+ Path,
+ PathBuf,
+ },
+};
+
+use super::error;
+use crate::git::{
+ self,
+ if_not_found_none,
+};
+
+pub trait BlobData: Sized {
+ type Error;
+
+ const MAX_BYTES: usize;
+
+ fn from_blob(data: &[u8]) -> Result<Self, Self::Error>;
+ fn write_blob<W: io::Write>(&self, writer: W) -> io::Result<()>;
+}
+
+pub trait TreeData: BlobData {
+ const BLOB_NAME: &'static str;
+}
+
+pub struct Blob<T> {
+ pub oid: git2::Oid,
+ pub content: T,
+}
+
+impl<T> Blob<T>
+where
+ T: TreeData,
+ T::Error: Into<crate::Error>,
+{
+ pub fn from_tree<'a>(
+ repo: &'a git2::Repository,
+ tree: &git2::Tree<'a>,
+ ) -> Result<Blob<T>, error::FromTree> {
+ use error::FromTree::NotFound;
+
+ let entry = tree
+ .get_name(T::BLOB_NAME)
+ .ok_or(NotFound { name: T::BLOB_NAME })?;
+ Self::from_entry(repo, entry)
+ }
+
+ pub fn from_entry<'a>(
+ repo: &'a git2::Repository,
+ entry: git2::TreeEntry<'a>,
+ ) -> Result<Self, error::FromTree> {
+ use error::FromTree::{
+ BlobSize,
+ TypeConversion,
+ TypeMismatch,
+ };
+
+ let blob = entry
+ .to_object(repo)?
+ .into_blob()
+ .map_err(|obj| TypeMismatch {
+ name: T::BLOB_NAME,
+ kind: obj.kind(),
+ })?;
+ let sz = blob.size();
+ if sz > T::MAX_BYTES {
+ return Err(BlobSize {
+ max: T::MAX_BYTES,
+ found: sz,
+ });
+ }
+ let content = T::from_blob(blob.content())
+ .map_err(Into::into)
+ .map_err(TypeConversion)?;
+
+ Ok(Self {
+ oid: entry.id(),
+ content,
+ })
+ }
+}
+
+pub trait Foldable {
+ fn folded_name(&self) -> String;
+}
+
+pub trait Seen {
+ fn in_odb(&self, odb: &git2::Odb) -> git::Result<bool>;
+ fn in_tree(&self, tree: &git2::Tree) -> git::Result<bool>;
+}
+
+impl<T> Seen for T
+where
+ T: BlobData + Foldable,
+{
+ fn in_odb(&self, odb: &git2::Odb) -> git::Result<bool> {
+ let hash = blob_hash(self)?;
+ Ok(odb.exists(hash))
+ }
+
+ fn in_tree(&self, tree: &git2::Tree) -> git::Result<bool> {
+ let path = shard_path(&self.folded_name());
+ Ok(if_not_found_none(tree.get_path(&path))?.is_some())
+ }
+}
+
+pub fn to_tree<T: TreeData>(
+ repo: &git2::Repository,
+ tree: &mut git2::TreeBuilder,
+ data: &T,
+) -> git::Result<()> {
+ tree.insert(
+ T::BLOB_NAME,
+ to_blob(repo, data)?,
+ git2::FileMode::Blob.into(),
+ )?;
+ Ok(())
+}
+
+pub fn to_blob<T: BlobData>(repo: &git2::Repository, data: &T) -> git::Result<git2::Oid> {
+ let mut writer = repo.blob_writer(None)?;
+ data.write_blob(&mut writer).map_err(|e| {
+ git2::Error::new(
+ git2::ErrorCode::GenericError,
+ git2::ErrorClass::Object,
+ e.to_string(),
+ )
+ })?;
+ writer.commit()
+}
+
+pub fn blob_hash<T: BlobData>(data: &T) -> git::Result<git2::Oid> {
+ let mut buf = Vec::new();
+ data.write_blob(&mut buf).unwrap();
+ git::blob_hash(&buf)
+}
+
+pub fn write_sharded<F: Foldable>(
+ repo: &git2::Repository,
+ root: &mut git2::TreeBuilder,
+ item: &F,
+ blob: git2::Oid,
+) -> git::Result<()> {
+ let name = item.folded_name();
+ let (pre, suf) = name.split_at(2);
+ let shard = root
+ .get(pre)?
+ .map(|entry| entry.to_object(repo))
+ .transpose()?;
+ let mut sub = repo.treebuilder(shard.as_ref().and_then(git2::Object::as_tree))?;
+ sub.insert(suf, blob, git2::FileMode::Blob.into())?;
+ root.insert(pre, sub.write()?, git2::FileMode::Tree.into())?;
+
+ Ok(())
+}
+
+pub fn shard_path(name: &str) -> PathBuf {
+ let (pre, suf) = name.split_at(2);
+ Path::new(pre).join(suf)
+}
diff --git a/src/serde.rs b/src/serde.rs
new file mode 100644
index 0000000..cbbf6a9
--- /dev/null
+++ b/src/serde.rs
@@ -0,0 +1,28 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+pub mod display {
+ use std::{
+ fmt,
+ str::FromStr,
+ };
+
+ pub fn serialize<T, S>(v: &T, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ T: ToString,
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&v.to_string())
+ }
+
+ #[allow(unused)]
+ pub fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
+ where
+ T: FromStr,
+ T::Err: fmt::Display,
+ D: serde::Deserializer<'de>,
+ {
+ let s: &str = serde::Deserialize::deserialize(deserializer)?;
+ s.parse().map_err(serde::de::Error::custom)
+ }
+}
diff --git a/src/ssh.rs b/src/ssh.rs
new file mode 100644
index 0000000..3019d45
--- /dev/null
+++ b/src/ssh.rs
@@ -0,0 +1,5 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+pub mod agent;
+pub use ssh_key::*;
diff --git a/src/ssh/agent.rs b/src/ssh/agent.rs
new file mode 100644
index 0000000..c29ad62
--- /dev/null
+++ b/src/ssh/agent.rs
@@ -0,0 +1,279 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use std::{
+ env,
+ io::{
+ self,
+ ErrorKind::*,
+ },
+};
+
+use anyhow::Context;
+use ssh_encoding::{
+ CheckedSum,
+ Decode,
+ Encode,
+ Reader,
+ Writer,
+};
+use ssh_key::{
+ public::KeyData,
+ Algorithm,
+ HashAlg,
+ PublicKey,
+ Signature,
+};
+
+#[cfg(unix)]
+pub use std::os::unix::net::UnixStream;
+#[cfg(windows)]
+pub use uds_windows::UnixStram;
+
+const SSH_AUTH_SOCK: &str = "SSH_AUTH_SOCK";
+
+const MAX_AGENT_REPLY_LEN: usize = 256 * 1024;
+
+const SSH_AGENTC_REQUEST_IDENTITIES: u8 = 11;
+const SSH_AGENTC_SIGN_REQUEST: u8 = 13;
+const SSH_AGENT_FAILURE: u8 = 5;
+const SSH_AGENT_IDENTITIES_ANSWER: u8 = 12;
+const SSH_AGENT_RSA_SHA2_256: u32 = 2;
+const SSH_AGENT_RSA_SHA2_512: u32 = 4;
+const SSH_AGENT_SIGN_RESPONSE: u8 = 14;
+
+pub struct Client<T> {
+ conn: T,
+}
+
+impl Client<UnixStream> {
+ pub fn from_env() -> io::Result<Self> {
+ let path = env::var_os(SSH_AUTH_SOCK).ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::AddrNotAvailable,
+ "SSH_AUTH_SOCK environment variable not set",
+ )
+ })?;
+ UnixStream::connect(path).map(Self::from)
+ }
+}
+
+impl From<UnixStream> for Client<UnixStream> {
+ fn from(conn: UnixStream) -> Self {
+ Self { conn }
+ }
+}
+
+impl<'a> From<&'a UnixStream> for Client<&'a UnixStream> {
+ fn from(conn: &'a UnixStream) -> Self {
+ Self { conn }
+ }
+}
+
+impl<T> Client<T>
+where
+ T: io::Read + io::Write,
+{
+ pub fn sign(&mut self, key: &PublicKey, msg: impl AsRef<[u8]>) -> io::Result<Signature> {
+ request(
+ &mut self.conn,
+ SignRequest {
+ key,
+ msg: msg.as_ref(),
+ },
+ )
+ .map(|SignResponse { sig }| sig)
+ }
+
+ pub fn list_keys(&mut self) -> io::Result<Vec<PublicKey>> {
+ request(&mut self.conn, RequestIdentities).map(|IdentitiesAnswer { keys }| keys)
+ }
+}
+
+trait Request: Encode<Error = crate::Error> {
+ type Response: Response;
+}
+
+trait Response: Decode<Error = crate::Error> {
+ const SUCCESS: u8;
+}
+
+fn request<I, T>(mut io: I, req: T) -> io::Result<T::Response>
+where
+ I: io::Read + io::Write,
+ T: Request,
+{
+ send(&mut io, req)?;
+ let resp = recv(&mut io)?;
+ let mut reader = resp.as_slice();
+ match u8::decode(&mut reader).map_err(|_| unknown_response())? {
+ x if x == T::Response::SUCCESS => T::Response::decode(&mut reader).map_err(decode),
+ SSH_AGENT_FAILURE => Err(agent_error()),
+ _ => Err(unknown_response()),
+ }
+}
+
+fn send<W, T>(mut io: W, req: T) -> io::Result<()>
+where
+ W: io::Write,
+ T: Encode<Error = crate::Error>,
+{
+ let len = req.encoded_len_prefixed().map_err(encode)?;
+ let mut buf = Vec::with_capacity(len);
+ req.encode_prefixed(&mut buf).map_err(encode)?;
+
+ io.write_all(&buf)?;
+ io.flush()?;
+
+ Ok(())
+}
+
+fn recv<R: io::Read>(mut io: R) -> io::Result<Vec<u8>> {
+ let want = {
+ let mut buf = [0; 4];
+ io.read_exact(&mut buf)?;
+ u32::from_be_bytes(buf) as usize
+ };
+
+ if want < 1 {
+ return Err(incomplete_response());
+ }
+ if want > MAX_AGENT_REPLY_LEN {
+ return Err(reponse_too_large());
+ }
+
+ let mut buf = vec![0; want];
+ io.read_exact(&mut buf)?;
+
+ Ok(buf)
+}
+
+struct SignRequest<'a> {
+ key: &'a PublicKey,
+ msg: &'a [u8],
+}
+
+impl Request for SignRequest<'_> {
+ type Response = SignResponse;
+}
+
+impl Encode for SignRequest<'_> {
+ type Error = crate::Error;
+
+ fn encoded_len(&self) -> Result<usize, Self::Error> {
+ Ok([
+ self.key.key_data().encoded_len_prefixed()?,
+ self.msg.encoded_len()?,
+ SSH_AGENTC_SIGN_REQUEST.encoded_len()?,
+ 4, // flags
+ ]
+ .checked_sum()?)
+ }
+
+ fn encode(&self, writer: &mut impl Writer) -> Result<(), Self::Error> {
+ SSH_AGENTC_SIGN_REQUEST.encode(writer)?;
+ self.key.key_data().encode_prefixed(writer)?;
+ self.msg.encode(writer)?;
+ let flags = match self.key.algorithm() {
+ Algorithm::Rsa { hash } => match hash {
+ Some(HashAlg::Sha256) => SSH_AGENT_RSA_SHA2_256,
+ _ => SSH_AGENT_RSA_SHA2_512, // sane default
+ },
+ _ => 0,
+ };
+ flags.encode(writer)?;
+ Ok(())
+ }
+}
+
+struct SignResponse {
+ sig: Signature,
+}
+
+impl Response for SignResponse {
+ const SUCCESS: u8 = SSH_AGENT_SIGN_RESPONSE;
+}
+
+impl Decode for SignResponse {
+ type Error = crate::Error;
+
+ fn decode(reader: &mut impl Reader) -> Result<Self, Self::Error> {
+ let sig = reader.read_prefixed(Signature::decode)?;
+ Ok(Self { sig })
+ }
+}
+
+struct RequestIdentities;
+
+impl Request for RequestIdentities {
+ type Response = IdentitiesAnswer;
+}
+
+impl Encode for RequestIdentities {
+ type Error = crate::Error;
+
+ fn encoded_len(&self) -> Result<usize, Self::Error> {
+ Ok(SSH_AGENTC_REQUEST_IDENTITIES.encoded_len()?)
+ }
+
+ fn encode(&self, writer: &mut impl Writer) -> Result<(), Self::Error> {
+ Ok(SSH_AGENTC_REQUEST_IDENTITIES.encode(writer)?)
+ }
+}
+
+struct IdentitiesAnswer {
+ keys: Vec<PublicKey>,
+}
+
+impl Response for IdentitiesAnswer {
+ const SUCCESS: u8 = SSH_AGENT_IDENTITIES_ANSWER;
+}
+
+impl Decode for IdentitiesAnswer {
+ type Error = crate::Error;
+
+ fn decode(reader: &mut impl Reader) -> Result<Self, Self::Error> {
+ let nkeys = usize::decode(reader).context("nkeys")?;
+ let mut keys = Vec::with_capacity(nkeys);
+
+ for _ in 0..nkeys {
+ let key_data = reader.read_prefixed(KeyData::decode).context("key data")?;
+ let comment = String::decode(reader).context("comment")?;
+ keys.push(PublicKey::new(key_data, comment));
+ }
+
+ Ok(Self { keys })
+ }
+}
+
+fn e(kind: io::ErrorKind, msg: &str) -> io::Error {
+ io::Error::new(kind, msg)
+}
+
+fn ee(kind: io::ErrorKind, e: crate::Error) -> io::Error {
+ io::Error::new(kind, e)
+}
+
+fn incomplete_response() -> io::Error {
+ e(UnexpectedEof, "incomplete response")
+}
+
+fn reponse_too_large() -> io::Error {
+ e(Unsupported, "response payload too large")
+}
+
+fn encode(e: crate::Error) -> io::Error {
+ ee(InvalidData, e.context("failed to encode request"))
+}
+
+fn decode(e: crate::Error) -> io::Error {
+ ee(InvalidData, e.context("failed to decode response"))
+}
+
+fn agent_error() -> io::Error {
+ e(Other, "error response from agent")
+}
+
+fn unknown_response() -> io::Error {
+ e(Unsupported, "unknown response")
+}
diff --git a/src/str.rs b/src/str.rs
new file mode 100644
index 0000000..1825e06
--- /dev/null
+++ b/src/str.rs
@@ -0,0 +1,94 @@
+// Copyright © 2022 Kim Altintop <kim@eagain.io>
+// SPDX-License-Identifier: GPL-2.0-only WITH openvpn-openssl-exception
+
+use core::fmt;
+use std::{
+ ops::Deref,
+ str::FromStr,
+};
+
+use anyhow::ensure;
+
+// A variable-length string type with a maximum length `N`.
+#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize)]
+pub struct Varchar<T, const N: usize>(T);
+
+impl<T, const N: usize> Varchar<T, N>
+where
+ T: AsRef<str>,
+{
+ pub fn len(&self) -> usize {
+ self.0.as_ref().len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.0.as_ref().is_empty()
+ }
+
+ fn try_from_t(t: T) -> crate::Result<Self> {
+ let len = t.as_ref().len();
+ ensure!(len <= N, "string length exceeds {N}: {len}");
+ Ok(Self(t))
+ }
+}
+
+impl<const N: usize> Varchar<String, N> {
+ pub const fn new() -> Self {
+ Self(String::new())
+ }
+}
+
+impl<const N: usize> TryFrom<String> for Varchar<String, N> {
+ type Error = crate::Error;
+
+ fn try_from(s: String) -> Result<Self, Self::Error> {
+ Self::try_from_t(s)
+ }
+}
+
+impl<const N: usize> FromStr for Varchar<String, N> {
+ type Err = crate::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Self::try_from(s.to_owned())
+ }
+}
+
+impl<'a, const N: usize> TryFrom<&'a str> for Varchar<&'a str, N> {
+ type Error = crate::Error;
+
+ fn try_from(s: &'a str) -> Result<Self, Self::Error> {
+ Self::try_from_t(s)
+ }
+}
+
+impl<T, const N: usize> Deref for Varchar<T, N> {
+ type Target = T;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl<T, const N: usize> fmt::Display for Varchar<T, N>
+where
+ T: AsRef<str>,
+{
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.0.as_ref())
+ }
+}
+
+impl<'de, T, const N: usize> serde::Deserialize<'de> for Varchar<T, N>
+where
+ T: serde::Deserialize<'de> + TryInto<Self>,
+ <T as TryInto<Self>>::Error: fmt::Display,
+{
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let t = T::deserialize(deserializer)?;
+ t.try_into().map_err(serde::de::Error::custom)
+ }
+}