diff --git a/Cargo.lock b/Cargo.lock index f0e1bc541d..3ea40fa0f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2795,6 +2795,7 @@ dependencies = [ "async-trait", "config", "git2", + "hex", "itertools 0.11.0", "jj-lib", "rand", diff --git a/cli/examples/custom-backend/main.rs b/cli/examples/custom-backend/main.rs index 3827f59c2c..4bc1c797e3 100644 --- a/cli/examples/custom-backend/main.rs +++ b/cli/examples/custom-backend/main.rs @@ -27,7 +27,8 @@ use jj_lib::git_backend::GitBackend; use jj_lib::repo::StoreFactories; use jj_lib::repo_path::RepoPath; use jj_lib::settings::UserSettings; -use jj_lib::workspace::Workspace; +use jj_lib::signing::Signer; +use jj_lib::workspace::{Workspace, WorkspaceInitError}; #[derive(clap::Parser, Clone, Debug)] enum CustomCommands { @@ -59,6 +60,8 @@ fn run_custom_command( command_helper.settings(), wc_path, &|settings, store_path| Ok(Box::new(JitBackend::init(settings, store_path)?)), + Signer::from_settings(command_helper.settings()) + .map_err(WorkspaceInitError::SignInit)?, )?; Ok(()) } diff --git a/cli/examples/custom-working-copy/main.rs b/cli/examples/custom-working-copy/main.rs index a1c50f3880..5101af37af 100644 --- a/cli/examples/custom-working-copy/main.rs +++ b/cli/examples/custom-working-copy/main.rs @@ -28,12 +28,15 @@ use jj_lib::op_store::{OperationId, WorkspaceId}; use jj_lib::repo::ReadonlyRepo; use jj_lib::repo_path::RepoPathBuf; use jj_lib::settings::UserSettings; +use jj_lib::signing::Signer; use jj_lib::store::Store; use jj_lib::working_copy::{ CheckoutError, CheckoutStats, LockedWorkingCopy, ResetError, SnapshotError, SnapshotOptions, WorkingCopy, WorkingCopyStateError, }; -use jj_lib::workspace::{default_working_copy_factories, WorkingCopyInitializer, Workspace}; +use jj_lib::workspace::{ + default_working_copy_factories, WorkingCopyInitializer, Workspace, WorkspaceInitError, +}; #[derive(clap::Parser, Clone, Debug)] enum CustomCommands { @@ -58,6 +61,8 @@ fn run_custom_command( command_helper.settings(), wc_path, &backend_initializer, + Signer::from_settings(command_helper.settings()) + .map_err(WorkspaceInitError::SignInit)?, &ReadonlyRepo::default_op_store_initializer(), &ReadonlyRepo::default_op_heads_store_initializer(), &ReadonlyRepo::default_index_store_initializer(), diff --git a/lib/src/repo.rs b/lib/src/repo.rs index 15b7f114e1..2765766ffc 100644 --- a/lib/src/repo.rs +++ b/lib/src/repo.rs @@ -119,8 +119,6 @@ pub enum RepoInitError { Backend(#[from] BackendInitError), #[error(transparent)] Path(#[from] PathError), - #[error(transparent)] - SignInit(#[from] SignInitError), } impl ReadonlyRepo { @@ -143,10 +141,12 @@ impl ReadonlyRepo { &|_settings, store_path| Box::new(DefaultSubmoduleStore::init(store_path)) } + #[allow(clippy::too_many_arguments)] pub fn init( user_settings: &UserSettings, repo_path: &Path, backend_initializer: &BackendInitializer, + signer: Signer, op_store_initializer: &OpStoreInitializer, op_heads_store_initializer: &OpHeadsStoreInitializer, index_store_initializer: &IndexStoreInitializer, @@ -159,7 +159,6 @@ impl ReadonlyRepo { let backend = backend_initializer(user_settings, &store_path)?; let backend_path = store_path.join("type"); fs::write(&backend_path, backend.name()).context(&backend_path)?; - let signer = Signer::from_settings(user_settings)?; let store = Store::new(backend, signer, user_settings.use_tree_conflict_format()); let repo_settings = user_settings.with_repo(&repo_path).unwrap(); diff --git a/lib/src/workspace.rs b/lib/src/workspace.rs index da386a7633..adfb700373 100644 --- a/lib/src/workspace.rs +++ b/lib/src/workspace.rs @@ -36,7 +36,7 @@ use crate::repo::{ StoreFactories, StoreLoadError, SubmoduleStoreInitializer, }; use crate::settings::UserSettings; -use crate::signing::SignInitError; +use crate::signing::{SignInitError, Signer}; use crate::store::Store; use crate::working_copy::{ CheckoutError, CheckoutStats, LockedWorkingCopy, WorkingCopy, WorkingCopyStateError, @@ -149,7 +149,8 @@ impl Workspace { ) -> Result<(Self, Arc), WorkspaceInitError> { let backend_initializer: &'static BackendInitializer = &|_settings, store_path| Ok(Box::new(LocalBackend::init(store_path))); - Self::init_with_backend(user_settings, workspace_root, backend_initializer) + let signer = Signer::from_settings(user_settings)?; + Self::init_with_backend(user_settings, workspace_root, backend_initializer, signer) } /// Initializes a workspace with a new Git backend and bare Git repo in @@ -160,7 +161,8 @@ impl Workspace { ) -> Result<(Self, Arc), WorkspaceInitError> { let backend_initializer: &'static BackendInitializer = &|settings, store_path| Ok(Box::new(GitBackend::init_internal(settings, store_path)?)); - Self::init_with_backend(user_settings, workspace_root, backend_initializer) + let signer = Signer::from_settings(user_settings)?; + Self::init_with_backend(user_settings, workspace_root, backend_initializer, signer) } /// Initializes a workspace with a new Git backend and Git repo that shares @@ -189,7 +191,8 @@ impl Workspace { Ok(Box::new(backend)) } }; - Self::init_with_backend(user_settings, workspace_root, &backend_initializer) + let signer = Signer::from_settings(user_settings)?; + Self::init_with_backend(user_settings, workspace_root, &backend_initializer, signer) } /// Initializes a workspace with an existing Git repo at the specified path. @@ -221,7 +224,8 @@ impl Workspace { Ok(Box::new(backend)) } }; - Self::init_with_backend(user_settings, workspace_root, &backend_initializer) + let signer = Signer::from_settings(user_settings)?; + Self::init_with_backend(user_settings, workspace_root, &backend_initializer, signer) } #[allow(clippy::too_many_arguments)] @@ -229,6 +233,7 @@ impl Workspace { user_settings: &UserSettings, workspace_root: &Path, backend_initializer: &BackendInitializer, + signer: Signer, op_store_initializer: &OpStoreInitializer, op_heads_store_initializer: &OpHeadsStoreInitializer, index_store_initializer: &IndexStoreInitializer, @@ -244,6 +249,7 @@ impl Workspace { user_settings, &repo_dir, backend_initializer, + signer, op_store_initializer, op_heads_store_initializer, index_store_initializer, @@ -252,7 +258,6 @@ impl Workspace { .map_err(|repo_init_err| match repo_init_err { RepoInitError::Backend(err) => WorkspaceInitError::Backend(err), RepoInitError::Path(err) => WorkspaceInitError::Path(err), - RepoInitError::SignInit(err) => WorkspaceInitError::SignInit(err), })?; let (working_copy, repo) = init_working_copy( user_settings, @@ -276,11 +281,13 @@ impl Workspace { user_settings: &UserSettings, workspace_root: &Path, backend_initializer: &BackendInitializer, + signer: Signer, ) -> Result<(Self, Arc), WorkspaceInitError> { Self::init_with_factories( user_settings, workspace_root, backend_initializer, + signer, ReadonlyRepo::default_op_store_initializer(), ReadonlyRepo::default_op_heads_store_initializer(), ReadonlyRepo::default_index_store_initializer(), diff --git a/lib/tests/test_git.rs b/lib/tests/test_git.rs index 1885fb9737..516c782f0c 100644 --- a/lib/tests/test_git.rs +++ b/lib/tests/test_git.rs @@ -35,6 +35,7 @@ use jj_lib::op_store::{BranchTarget, RefTarget, RemoteRef, RemoteRefState}; use jj_lib::refs::BranchPushUpdate; use jj_lib::repo::{MutableRepo, ReadonlyRepo, Repo}; use jj_lib::settings::{GitSettings, UserSettings}; +use jj_lib::signing::Signer; use jj_lib::str_util::StringPattern; use jj_lib::workspace::Workspace; use maplit::{btreemap, hashset}; @@ -1125,6 +1126,7 @@ impl GitRepoData { &git_repo_dir, )?)) }, + Signer::from_settings(&settings).unwrap(), ReadonlyRepo::default_op_store_initializer(), ReadonlyRepo::default_op_heads_store_initializer(), ReadonlyRepo::default_index_store_initializer(), @@ -1990,6 +1992,7 @@ fn test_init() { &git_repo_dir, )?)) }, + Signer::from_settings(&settings).unwrap(), ReadonlyRepo::default_op_store_initializer(), ReadonlyRepo::default_op_heads_store_initializer(), ReadonlyRepo::default_index_store_initializer(), @@ -2315,6 +2318,7 @@ fn set_up_push_repos(settings: &UserSettings, temp_dir: &TempDir) -> PushTestSet &clone_repo_dir, )?)) }, + Signer::from_settings(settings).unwrap(), ReadonlyRepo::default_op_store_initializer(), ReadonlyRepo::default_op_heads_store_initializer(), ReadonlyRepo::default_index_store_initializer(), diff --git a/lib/tests/test_signing.rs b/lib/tests/test_signing.rs new file mode 100644 index 0000000000..1a4a165e25 --- /dev/null +++ b/lib/tests/test_signing.rs @@ -0,0 +1,171 @@ +use jj_lib::backend::{MillisSinceEpoch, Signature, Timestamp}; +use jj_lib::repo::Repo; +use jj_lib::settings::UserSettings; +use jj_lib::signing::{SigStatus, SignBehavior, Signer, Verification}; +use test_case::test_case; +use testutils::test_signing_backend::TestSigningBackend; +use testutils::{create_random_commit, write_random_commit, TestRepoBackend, TestWorkspace}; + +fn user_settings(sign_all: bool) -> UserSettings { + let config = testutils::base_config() + .add_source(config::File::from_str( + &format!( + r#" + signing.key = "impeccable" + signing.sign-all = {sign_all} + "# + ), + config::FileFormat::Toml, + )) + .build() + .unwrap(); + UserSettings::from_config(config) +} + +fn someone_else() -> Signature { + Signature { + name: "Someone Else".to_string(), + email: "someone-else@example.com".to_string(), + timestamp: Timestamp { + timestamp: MillisSinceEpoch(0), + tz_offset: 0, + }, + } +} + +fn good_verification() -> Option { + Some(Verification { + status: SigStatus::Good, + key: Some("impeccable".to_owned()), + display: None, + }) +} + +#[test_case(TestRepoBackend::Local ; "local backend")] +#[test_case(TestRepoBackend::Git ; "git backend")] +fn manual(backend: TestRepoBackend) { + let settings = user_settings(true); + + let signer = Signer::new(Some(Box::new(TestSigningBackend)), vec![]); + let test_workspace = TestWorkspace::init_with_backend_and_signer(&settings, backend, signer); + + let repo = &test_workspace.repo; + + let settings = settings.clone(); + let repo = repo.clone(); + let mut tx = repo.start_transaction(&settings, "test"); + let commit1 = create_random_commit(tx.mut_repo(), &settings) + .set_sign_behavior(SignBehavior::Own) + .write() + .unwrap(); + let commit2 = create_random_commit(tx.mut_repo(), &settings) + .set_sign_behavior(SignBehavior::Own) + .set_author(someone_else()) + .write() + .unwrap(); + tx.commit(); + + let commit1 = repo.store().get_commit(commit1.id()).unwrap(); + assert_eq!(commit1.verification().unwrap(), good_verification()); + + let commit2 = repo.store().get_commit(commit2.id()).unwrap(); + assert_eq!(commit2.verification().unwrap(), None); +} + +#[test_case(TestRepoBackend::Git ; "git backend")] +fn keep_on_rewrite(backend: TestRepoBackend) { + let settings = user_settings(true); + + let signer = Signer::new(Some(Box::new(TestSigningBackend)), vec![]); + let test_workspace = TestWorkspace::init_with_backend_and_signer(&settings, backend, signer); + + let repo = &test_workspace.repo; + + let settings = settings.clone(); + let repo = repo.clone(); + let mut tx = repo.start_transaction(&settings, "test"); + let commit = create_random_commit(tx.mut_repo(), &settings) + .set_sign_behavior(SignBehavior::Own) + .write() + .unwrap(); + tx.commit(); + + let mut tx = repo.start_transaction(&settings, "test"); + let mut_repo = tx.mut_repo(); + let rewritten = mut_repo.rewrite_commit(&settings, &commit).write().unwrap(); + + let commit = repo.store().get_commit(rewritten.id()).unwrap(); + assert_eq!(commit.verification().unwrap(), good_verification()); +} + +#[test_case(TestRepoBackend::Git ; "git backend")] +fn manual_drop_on_rewrite(backend: TestRepoBackend) { + let settings = user_settings(true); + + let signer = Signer::new(Some(Box::new(TestSigningBackend)), vec![]); + let test_workspace = TestWorkspace::init_with_backend_and_signer(&settings, backend, signer); + + let repo = &test_workspace.repo; + + let settings = settings.clone(); + let repo = repo.clone(); + let mut tx = repo.start_transaction(&settings, "test"); + let commit = create_random_commit(tx.mut_repo(), &settings) + .set_sign_behavior(SignBehavior::Own) + .write() + .unwrap(); + tx.commit(); + + let mut tx = repo.start_transaction(&settings, "test"); + let mut_repo = tx.mut_repo(); + let rewritten = mut_repo + .rewrite_commit(&settings, &commit) + .set_sign_behavior(SignBehavior::Drop) + .write() + .unwrap(); + + let commit = repo.store().get_commit(rewritten.id()).unwrap(); + assert_eq!(commit.verification().unwrap(), None); +} + +#[test_case(TestRepoBackend::Git ; "git backend")] +fn forced(backend: TestRepoBackend) { + let settings = user_settings(true); + + let signer = Signer::new(Some(Box::new(TestSigningBackend)), vec![]); + let test_workspace = TestWorkspace::init_with_backend_and_signer(&settings, backend, signer); + + let repo = &test_workspace.repo; + + let settings = settings.clone(); + let repo = repo.clone(); + let mut tx = repo.start_transaction(&settings, "test"); + let commit = create_random_commit(tx.mut_repo(), &settings) + .set_sign_behavior(SignBehavior::Force) + .set_author(someone_else()) + .write() + .unwrap(); + tx.commit(); + + let commit = repo.store().get_commit(commit.id()).unwrap(); + assert_eq!(commit.verification().unwrap(), good_verification()); +} + +#[test_case(TestRepoBackend::Git ; "git backend")] +fn configured(backend: TestRepoBackend) { + let settings = user_settings(true); + + let signer = Signer::new(Some(Box::new(TestSigningBackend)), vec![]); + let test_workspace = TestWorkspace::init_with_backend_and_signer(&settings, backend, signer); + + let repo = &test_workspace.repo; + + let settings = settings.clone(); + let repo = repo.clone(); + let mut tx = repo.start_transaction(&settings, "test"); + let commit = write_random_commit(tx.mut_repo(), &settings); + tx.commit(); + + let commit = repo.store().get_commit(commit.id()).unwrap(); + assert_eq!(commit.verification().unwrap(), good_verification()); +} diff --git a/lib/testutils/Cargo.toml b/lib/testutils/Cargo.toml index baefbe85e5..07429e3983 100644 --- a/lib/testutils/Cargo.toml +++ b/lib/testutils/Cargo.toml @@ -18,6 +18,7 @@ readme = { workspace = true } async-trait = { workspace = true } config = { workspace = true } git2 = { workspace = true } +hex = { workspace = true } itertools = { workspace = true } jj-lib = { workspace = true } rand = { workspace = true } diff --git a/lib/testutils/src/lib.rs b/lib/testutils/src/lib.rs index ffe157bb3d..d4e0a9a42f 100644 --- a/lib/testutils/src/lib.rs +++ b/lib/testutils/src/lib.rs @@ -32,6 +32,7 @@ use jj_lib::repo::{MutableRepo, ReadonlyRepo, Repo, RepoLoader, StoreFactories}; use jj_lib::repo_path::{RepoPath, RepoPathBuf}; use jj_lib::rewrite::RebasedDescendant; use jj_lib::settings::UserSettings; +use jj_lib::signing::Signer; use jj_lib::store::Store; use jj_lib::transaction::Transaction; use jj_lib::tree::Tree; @@ -43,6 +44,7 @@ use tempfile::TempDir; use crate::test_backend::TestBackend; pub mod test_backend; +pub mod test_signing_backend; pub fn hermetic_libgit2() { // libgit2 respects init.defaultBranch (and possibly other config @@ -139,6 +141,7 @@ impl TestRepo { &settings, &repo_dir, &move |settings, store_path| backend.init_backend(settings, store_path), + Signer::from_settings(&settings).unwrap(), ReadonlyRepo::default_op_store_initializer(), ReadonlyRepo::default_op_heads_store_initializer(), ReadonlyRepo::default_index_store_initializer(), @@ -175,6 +178,18 @@ impl TestWorkspace { } pub fn init_with_backend(settings: &UserSettings, backend: TestRepoBackend) -> Self { + Self::init_with_backend_and_signer( + settings, + backend, + Signer::from_settings(settings).unwrap(), + ) + } + + pub fn init_with_backend_and_signer( + settings: &UserSettings, + backend: TestRepoBackend, + signer: Signer, + ) -> Self { let temp_dir = new_temp_dir(); let workspace_root = temp_dir.path().join("repo"); @@ -184,6 +199,7 @@ impl TestWorkspace { settings, &workspace_root, &move |settings, store_path| backend.init_backend(settings, store_path), + signer, ) .unwrap(); diff --git a/lib/testutils/src/test_signing_backend.rs b/lib/testutils/src/test_signing_backend.rs new file mode 100644 index 0000000000..af6c7e5dd0 --- /dev/null +++ b/lib/testutils/src/test_signing_backend.rs @@ -0,0 +1,54 @@ +use hex::ToHex; +use jj_lib::content_hash::blake2b_hash; +use jj_lib::signing::{SigStatus, SignError, SignResult, SigningBackend, Verification}; + +#[derive(Debug)] +pub struct TestSigningBackend; + +const PREFIX: &str = "--- JJ-TEST-SIGNATURE ---\nKEY: "; + +impl SigningBackend for TestSigningBackend { + fn name(&self) -> &str { + "test" + } + + fn can_read(&self, signature: &[u8]) -> bool { + signature.starts_with(PREFIX.as_bytes()) + } + + fn sign(&self, data: &[u8], key: Option<&str>) -> SignResult> { + let key = key.unwrap_or_default(); + let mut body = Vec::with_capacity(data.len() + key.len()); + body.extend_from_slice(key.as_bytes()); + body.extend_from_slice(data); + + let hash: String = blake2b_hash(&body).encode_hex(); + + Ok(format!("{PREFIX}{key}\n{hash}").into_bytes()) + } + + fn verify(&self, data: &[u8], signature: &[u8]) -> SignResult { + let Some(key) = signature + .strip_prefix(PREFIX.as_bytes()) + .and_then(|s| s.splitn(2, |&b| b == b'\n').next()) + else { + return Err(SignError::InvalidSignatureFormat); + }; + let key = (!key.is_empty()).then_some(std::str::from_utf8(key).unwrap().to_owned()); + + let sig = self.sign(data, key.as_deref())?; + if sig == signature { + Ok(Verification { + status: SigStatus::Good, + key, + display: None, + }) + } else { + Ok(Verification { + status: SigStatus::Bad, + key, + display: None, + }) + } + } +}