diff --git a/cli/src/command_error.rs b/cli/src/command_error.rs index cf4b570e5f..9957aa5581 100644 --- a/cli/src/command_error.rs +++ b/cli/src/command_error.rs @@ -365,6 +365,7 @@ impl From for CommandError { match err { DiffRenderError::DiffGenerate(_) => user_error(err), DiffRenderError::Backend(err) => err.into(), + DiffRenderError::AccessDenied { .. } => user_error(err), DiffRenderError::Io(err) => err.into(), } } diff --git a/cli/src/commands/cat.rs b/cli/src/commands/cat.rs index 3e263852ed..8e5d8a4acd 100644 --- a/cli/src/commands/cat.rs +++ b/cli/src/commands/cat.rs @@ -104,6 +104,13 @@ fn write_tree_entries>( let materialized = materialize_tree_value(repo.store(), path.as_ref(), value).block_on()?; match materialized { MaterializedTreeValue::Absent => panic!("absent values should be excluded"), + MaterializedTreeValue::AccessDenied(err) => { + let ui_path = workspace_command.format_file_path(path.as_ref()); + writeln!( + ui.warning_default(), + "Path '{ui_path}' exists but access is denied: {err}" + )?; + } MaterializedTreeValue::File { mut reader, .. } => { io::copy(&mut reader, &mut ui.stdout_formatter().as_mut())?; } diff --git a/cli/src/config/colors.toml b/cli/src/config/colors.toml index 4d95523c3e..286f7fad4b 100644 --- a/cli/src/config/colors.toml +++ b/cli/src/config/colors.toml @@ -81,6 +81,7 @@ "diff removed" = "red" "diff added" = "green" "diff modified" = "cyan" +"diff access-denied" = { bg = "red" } "op_log id" = "blue" "op_log user" = "yellow" diff --git a/cli/src/diff_util.rs b/cli/src/diff_util.rs index 22febd37fa..4b58e3856e 100644 --- a/cli/src/diff_util.rs +++ b/cli/src/diff_util.rs @@ -195,6 +195,11 @@ pub enum DiffRenderError { DiffGenerate(#[source] DiffGenerateError), #[error(transparent)] Backend(#[from] BackendError), + #[error("Access denied to {path}: {source}")] + AccessDenied { + path: String, + source: Box, + }, #[error(transparent)] Io(#[from] io::Error), } @@ -437,6 +442,10 @@ fn file_content_for_diff(reader: &mut dyn io::Read) -> io::Result { fn diff_content(path: &RepoPath, value: MaterializedTreeValue) -> io::Result { match value { MaterializedTreeValue::Absent => Ok(FileContent::empty()), + MaterializedTreeValue::AccessDenied(err) => Ok(FileContent { + is_binary: false, + contents: format!("Access denied: {err}").into_bytes(), + }), MaterializedTreeValue::File { mut reader, .. } => { file_content_for_diff(&mut reader).map_err(Into::into) } @@ -469,6 +478,7 @@ fn basic_diff_file_type(value: &MaterializedTreeValue) -> &'static str { MaterializedTreeValue::Absent => { panic!("absent path in diff"); } + MaterializedTreeValue::AccessDenied(_) => "access denied", MaterializedTreeValue::File { executable, .. } => { if *executable { "executable file" @@ -496,6 +506,18 @@ pub fn show_color_words_diff( while let Some((path, diff)) = diff_stream.next().await { let ui_path = path_converter.format_file_path(&path); let (left_value, right_value) = diff?; + + match (&left_value, &right_value) { + (_, MaterializedTreeValue::AccessDenied(source)) + | (MaterializedTreeValue::AccessDenied(source), _) => { + writeln!( + formatter.labeled("access-denied"), + "Access denied to {ui_path}: {source}" + )?; + continue; + } + _ => {} + } if left_value.is_absent() { let description = basic_diff_file_type(&right_value); writeln!( @@ -610,7 +632,10 @@ struct GitDiffPart { content: Vec, } -fn git_diff_part(path: &RepoPath, value: MaterializedTreeValue) -> io::Result { +fn git_diff_part( + path: &RepoPath, + value: MaterializedTreeValue, +) -> Result { let mode; let hash; let mut contents: Vec; @@ -618,6 +643,12 @@ fn git_diff_part(path: &RepoPath, value: MaterializedTreeValue) -> io::Result { panic!("Absent path {path:?} in diff should have been handled by caller"); } + MaterializedTreeValue::AccessDenied(err) => { + return Err(DiffRenderError::AccessDenied { + path: path.as_internal_file_string().to_owned(), + source: err, + }); + } MaterializedTreeValue::File { id, executable, diff --git a/cli/src/merge_tools/builtin.rs b/cli/src/merge_tools/builtin.rs index fbbf2888b6..f0cc339dd8 100644 --- a/cli/src/merge_tools/builtin.rs +++ b/cli/src/merge_tools/builtin.rs @@ -137,6 +137,14 @@ fn read_file_contents( file_mode: scm_record::FileMode::absent(), contents: FileContents::Absent, }), + MaterializedTreeValue::AccessDenied(err) => Ok(FileInfo { + file_mode: scm_record::FileMode(mode::NORMAL), + contents: FileContents::Text { + contents: format!("Access denied: {err}"), + hash: None, + num_bytes: 0, + }, + }), MaterializedTreeValue::File { id, diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index 6387d1f4df..b0a207254d 100644 --- a/cli/tests/runner.rs +++ b/cli/tests/runner.rs @@ -9,6 +9,7 @@ fn test_no_forgotten_test_files() { } mod test_abandon_command; +mod test_acls; mod test_advance_branches; mod test_alias; mod test_branch_command; diff --git a/cli/tests/test_acls.rs b/cli/tests/test_acls.rs new file mode 100644 index 0000000000..8db8967bc9 --- /dev/null +++ b/cli/tests/test_acls.rs @@ -0,0 +1,121 @@ +// Copyright 2024 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use jj_lib::secret_backend::SecretBackend; + +use crate::common::{get_stderr_string, get_stdout_string, TestEnvironment}; + +#[test] +fn test_diff() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + std::fs::create_dir(repo_path.join("dir")).unwrap(); + std::fs::write(repo_path.join("a-first"), "foo\n").unwrap(); + std::fs::write(repo_path.join("deleted-secret"), "foo\n").unwrap(); + std::fs::write(repo_path.join("dir").join("secret"), "foo\n").unwrap(); + std::fs::write(repo_path.join("modified-secret"), "foo\n").unwrap(); + std::fs::write(repo_path.join("z-last"), "foo\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new"]); + std::fs::write(repo_path.join("a-first"), "bar\n").unwrap(); + std::fs::remove_file(repo_path.join("deleted-secret")).unwrap(); + std::fs::write(repo_path.join("added-secret"), "bar\n").unwrap(); + std::fs::write(repo_path.join("dir").join("secret"), "bar\n").unwrap(); + std::fs::write(repo_path.join("modified-secret"), "bar\n").unwrap(); + std::fs::write(repo_path.join("z-last"), "bar\n").unwrap(); + + SecretBackend::adopt_git_repo(&repo_path); + + let stdout = test_env.jj_cmd_success(&repo_path, &["diff", "--color-words"]); + insta::assert_snapshot!(stdout.replace('\\', "/"), @r###" + Modified regular file a-first: + 1 1: foobar + Access denied to added-secret: No access + Access denied to deleted-secret: No access + Access denied to dir/secret: No access + Access denied to modified-secret: No access + Modified regular file z-last: + 1 1: foobar + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["diff", "--summary"]); + insta::assert_snapshot!(stdout.replace('\\', "/"), @r###" + M a-first + A added-secret + D deleted-secret + M dir/secret + M modified-secret + M z-last + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["diff", "--types"]); + insta::assert_snapshot!(stdout.replace('\\', "/"), @r###" + FF a-first + -F added-secret + F- deleted-secret + FF dir/secret + FF modified-secret + FF z-last + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["diff", "--stat"]); + insta::assert_snapshot!(stdout.replace('\\', "/"), @r###" + a-first | 2 +- + added-secret | 1 + + deleted-secret | 1 - + dir/secret | 0 + modified-secret | 0 + z-last | 2 +- + 6 files changed, 3 insertions(+), 3 deletions(-) + "###); + let assert = test_env + .jj_cmd(&repo_path, &["diff", "--git"]) + .assert() + .failure(); + insta::assert_snapshot!(get_stdout_string(&assert).replace('\\', "/"), @r###" + diff --git a/a-first b/a-first + index 257cc5642c...5716ca5987 100644 + --- a/a-first + +++ b/a-first + @@ -1,1 +1,1 @@ + -foo + +bar + "###); + insta::assert_snapshot!(get_stderr_string(&assert), @r###" + Error: Access denied to added-secret: No access + Caused by: No access + "###); + + // TODO: Test external tool +} + +#[test] +fn test_cat() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + let repo_path = test_env.env_root().join("repo"); + + std::fs::write(repo_path.join("a-first"), "foo\n").unwrap(); + std::fs::write(repo_path.join("secret"), "bar\n").unwrap(); + std::fs::write(repo_path.join("z-last"), "baz\n").unwrap(); + + SecretBackend::adopt_git_repo(&repo_path); + + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["cat", "."]); + insta::assert_snapshot!(stdout.replace('\\', "/"), @r###" + foo + baz + "###); + insta::assert_snapshot!(stderr.replace('\\', "/"), @r###" + Warning: Path 'secret' exists but access is denied: No access + "###); +} diff --git a/lib/src/backend.rs b/lib/src/backend.rs index 1b88e9a60f..5b532037c2 100644 --- a/lib/src/backend.rs +++ b/lib/src/backend.rs @@ -193,6 +193,12 @@ pub enum BackendError { hash: String, source: Box, }, + #[error("Access denied to read object {hash} of type {object_type}")] + ReadAccessDenied { + object_type: &'static str, + hash: String, + source: Box, + }, #[error("Could not write object of type {object_type}")] WriteObject { object_type: &'static str, diff --git a/lib/src/conflicts.rs b/lib/src/conflicts.rs index 8163d0d090..86a9a557ee 100644 --- a/lib/src/conflicts.rs +++ b/lib/src/conflicts.rs @@ -21,7 +21,7 @@ use futures::StreamExt; use itertools::Itertools; use regex::bytes::Regex; -use crate::backend::{BackendResult, CommitId, FileId, SymlinkId, TreeId, TreeValue}; +use crate::backend::{BackendError, BackendResult, CommitId, FileId, SymlinkId, TreeId, TreeValue}; use crate::diff::{find_line_ranges, Diff, DiffHunk}; use crate::files; use crate::files::{ContentHunk, MergeResult}; @@ -127,6 +127,7 @@ pub async fn materialize( /// e.g. the working copy or in a diff. pub enum MaterializedTreeValue { Absent, + AccessDenied(Box), File { id: FileId, executable: bool, @@ -161,6 +162,19 @@ pub async fn materialize_tree_value( store: &Store, path: &RepoPath, value: MergedTreeValue, +) -> BackendResult { + match materialize_tree_value_no_access_denied(store, path, value).await { + Err(BackendError::ReadAccessDenied { source, .. }) => { + Ok(MaterializedTreeValue::AccessDenied(source)) + } + result => result, + } +} + +async fn materialize_tree_value_no_access_denied( + store: &Store, + path: &RepoPath, + value: MergedTreeValue, ) -> BackendResult { match value.into_resolved() { Ok(None) => Ok(MaterializedTreeValue::Absent), diff --git a/lib/src/lib.rs b/lib/src/lib.rs index e96d1d1788..1d01a2bf21 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -71,6 +71,8 @@ pub mod revset; pub mod revset_graph; mod revset_parser; pub mod rewrite; +#[cfg(feature = "testing")] +pub mod secret_backend; pub mod settings; pub mod signing; pub mod simple_op_heads_store; diff --git a/lib/src/local_working_copy.rs b/lib/src/local_working_copy.rs index d585c60416..d67bcbb053 100644 --- a/lib/src/local_working_copy.rs +++ b/lib/src/local_working_copy.rs @@ -835,7 +835,7 @@ impl TreeState { let tree = self.current_tree().unwrap(); let tree_paths: HashSet<_> = tree .entries_matching(sparse_matcher.as_ref()) - .map(|(path, _)| path) + .filter_map(|(path, result)| result.is_ok().then_some(path)) .collect(); let file_states = self.file_states.all(); let state_paths: HashSet<_> = file_states.paths().map(|path| path.to_owned()).collect(); @@ -1368,7 +1368,7 @@ impl TreeState { } // TODO: Check that the file has not changed before overwriting/removing it. let file_state = match after { - MaterializedTreeValue::Absent => { + MaterializedTreeValue::Absent | MaterializedTreeValue::AccessDenied(_) => { let mut parent_dir = disk_path.parent().unwrap(); loop { if fs::remove_dir(parent_dir).is_err() { diff --git a/lib/src/repo.rs b/lib/src/repo.rs index e77b5464fb..15ab992c7b 100644 --- a/lib/src/repo.rs +++ b/lib/src/repo.rs @@ -383,6 +383,15 @@ impl Default for StoreFactories { GitBackend::name(), Box::new(|settings, store_path| Ok(Box::new(GitBackend::load(settings, store_path)?))), ); + #[cfg(feature = "testing")] + factories.add_backend( + crate::secret_backend::SecretBackend::name(), + Box::new(|settings, store_path| { + Ok(Box::new(crate::secret_backend::SecretBackend::load( + settings, store_path, + )?)) + }), + ); // OpStores factories.add_op_store( diff --git a/lib/src/secret_backend.rs b/lib/src/secret_backend.rs new file mode 100644 index 0000000000..17d2d0ed9a --- /dev/null +++ b/lib/src/secret_backend.rs @@ -0,0 +1,173 @@ +// Copyright 2024 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Provides a backend for testing ACLs + +use std::any::Any; +use std::io::Read; +use std::path::Path; +use std::time::SystemTime; + +use async_trait::async_trait; + +use crate::backend::{ + Backend, BackendError, BackendLoadError, BackendResult, ChangeId, Commit, CommitId, Conflict, + ConflictId, FileId, SigningFn, SymlinkId, Tree, TreeId, +}; +use crate::git_backend::GitBackend; +use crate::index::Index; +use crate::object_id::ObjectId; +use crate::repo_path::RepoPath; +use crate::settings::UserSettings; + +const SECRET_CONTENTS_HEX: [&str; 2] = [ + "d97c5eada5d8c52079031eef0107a4430a9617c5", // "secret\n" + "536aca34dbae6b2b8af26bebdcba83543c9546f0", // "secret" +]; + +/// A commit backend that's completely compatible with the Git backend, except +/// that it refuses to read files and symlinks with the word "secret" in the +/// path, or "secret" or "secret\n" in the content. +#[derive(Debug)] +pub struct SecretBackend { + inner: GitBackend, +} + +impl SecretBackend { + /// "secret" + pub fn name() -> &'static str { + "secret" + } + + /// Loads the backend from the given path. + pub fn load(settings: &UserSettings, store_path: &Path) -> Result { + let inner = GitBackend::load(settings, store_path)?; + Ok(SecretBackend { inner }) + } + + /// Convert a git repo to using `SecretBackend` + // TODO: Avoid this hack + pub fn adopt_git_repo(workspace_path: &Path) { + std::fs::write( + workspace_path + .join(".jj") + .join("repo") + .join("store") + .join("type"), + Self::name(), + ) + .unwrap(); + } +} + +#[async_trait] +impl Backend for SecretBackend { + fn as_any(&self) -> &dyn Any { + self + } + + fn name(&self) -> &str { + SecretBackend::name() + } + + fn commit_id_length(&self) -> usize { + self.inner.commit_id_length() + } + + fn change_id_length(&self) -> usize { + self.inner.change_id_length() + } + + fn root_commit_id(&self) -> &CommitId { + self.inner.root_commit_id() + } + + fn root_change_id(&self) -> &ChangeId { + self.inner.root_change_id() + } + + fn empty_tree_id(&self) -> &TreeId { + self.inner.empty_tree_id() + } + + fn concurrency(&self) -> usize { + 1 + } + + async fn read_file(&self, path: &RepoPath, id: &FileId) -> BackendResult> { + if path.as_internal_file_string().contains("secret") + || SECRET_CONTENTS_HEX.contains(&id.hex().as_ref()) + { + return Err(BackendError::ReadAccessDenied { + object_type: "file", + hash: id.hex(), + source: "No access".into(), + }); + } + self.inner.read_file(path, id).await + } + + fn write_file(&self, path: &RepoPath, contents: &mut dyn Read) -> BackendResult { + self.inner.write_file(path, contents) + } + + async fn read_symlink(&self, path: &RepoPath, id: &SymlinkId) -> BackendResult { + if path.as_internal_file_string().contains("secret") + || SECRET_CONTENTS_HEX.contains(&id.hex().as_ref()) + { + return Err(BackendError::ReadAccessDenied { + object_type: "symlink", + hash: id.hex(), + source: "No access".into(), + }); + } + self.inner.read_symlink(path, id).await + } + + fn write_symlink(&self, path: &RepoPath, target: &str) -> BackendResult { + self.inner.write_symlink(path, target) + } + + async fn read_tree(&self, path: &RepoPath, id: &TreeId) -> BackendResult { + self.inner.read_tree(path, id).await + } + + fn write_tree(&self, path: &RepoPath, contents: &Tree) -> BackendResult { + self.inner.write_tree(path, contents) + } + + fn read_conflict(&self, path: &RepoPath, id: &ConflictId) -> BackendResult { + self.inner.read_conflict(path, id) + } + + fn write_conflict(&self, path: &RepoPath, contents: &Conflict) -> BackendResult { + self.inner.write_conflict(path, contents) + } + + async fn read_commit(&self, id: &CommitId) -> BackendResult { + self.inner.read_commit(id).await + } + + fn write_commit( + &self, + contents: Commit, + sign_with: Option<&mut SigningFn>, + ) -> BackendResult<(CommitId, Commit)> { + self.inner.write_commit(contents, sign_with) + } + + fn gc(&self, index: &dyn Index, keep_newer: SystemTime) -> BackendResult<()> { + self.inner.gc(index, keep_newer) + } +} diff --git a/lib/tests/test_local_working_copy.rs b/lib/tests/test_local_working_copy.rs index 8c06660e27..7ff7527910 100644 --- a/lib/tests/test_local_working_copy.rs +++ b/lib/tests/test_local_working_copy.rs @@ -29,12 +29,13 @@ use jj_lib::merged_tree::{MergedTree, MergedTreeBuilder}; use jj_lib::op_store::{OperationId, WorkspaceId}; use jj_lib::repo::{ReadonlyRepo, Repo}; use jj_lib::repo_path::{RepoPath, RepoPathBuf, RepoPathComponent}; +use jj_lib::secret_backend::SecretBackend; use jj_lib::settings::UserSettings; use jj_lib::working_copy::{CheckoutStats, SnapshotError, SnapshotOptions}; -use jj_lib::workspace::LockedWorkspace; +use jj_lib::workspace::{default_working_copy_factories, LockedWorkspace, Workspace}; use test_case::test_case; use testutils::{ - commit_with_tree, create_tree, write_random_commit, TestRepoBackend, TestWorkspace, + commit_with_tree, create_tree, write_random_commit, TestRepo, TestRepoBackend, TestWorkspace, }; fn to_owned_path_vec(paths: &[&RepoPath]) -> Vec { @@ -328,6 +329,61 @@ fn test_conflict_subdirectory() { .unwrap(); } +#[test] +fn test_acl() { + let settings = testutils::user_settings(); + let test_workspace = TestWorkspace::init_with_backend(&settings, TestRepoBackend::Git); + let repo = &test_workspace.repo; + let workspace_root = test_workspace.workspace.workspace_root().to_owned(); + + let secret_modified_path = RepoPath::from_internal_string("secret/modified"); + let secret_added_path = RepoPath::from_internal_string("secret/added"); + let secret_deleted_path = RepoPath::from_internal_string("secret/deleted"); + let became_secret_path = RepoPath::from_internal_string("file1"); + let became_public_path = RepoPath::from_internal_string("file2"); + let tree1 = create_tree( + repo, + &[ + (secret_modified_path, "0"), + (secret_deleted_path, "0"), + (became_secret_path, "public"), + (became_public_path, "secret"), + ], + ); + let tree2 = create_tree( + repo, + &[ + (secret_modified_path, "1"), + (secret_added_path, "1"), + (became_secret_path, "secret"), + (became_public_path, "public"), + ], + ); + let commit1 = commit_with_tree(repo.store(), tree1.id()); + let commit2 = commit_with_tree(repo.store(), tree2.id()); + SecretBackend::adopt_git_repo(&workspace_root); + + let mut ws = Workspace::load( + &settings, + &workspace_root, + &TestRepo::default_store_factories(), + &default_working_copy_factories(), + ) + .unwrap(); + ws.check_out(repo.op_id().clone(), None, &commit1).unwrap(); + assert!(!secret_modified_path.to_fs_path(&workspace_root).is_file()); + assert!(!secret_added_path.to_fs_path(&workspace_root).is_file()); + assert!(!secret_deleted_path.to_fs_path(&workspace_root).is_file()); + assert!(became_secret_path.to_fs_path(&workspace_root).is_file()); + assert!(!became_public_path.to_fs_path(&workspace_root).is_file()); + ws.check_out(repo.op_id().clone(), None, &commit2).unwrap(); + assert!(!secret_modified_path.to_fs_path(&workspace_root).is_file()); + assert!(!secret_added_path.to_fs_path(&workspace_root).is_file()); + assert!(!secret_deleted_path.to_fs_path(&workspace_root).is_file()); + assert!(!became_secret_path.to_fs_path(&workspace_root).is_file()); + assert!(became_public_path.to_fs_path(&workspace_root).is_file()); +} + #[test] fn test_tree_builder_file_directory_transition() { let settings = testutils::user_settings(); diff --git a/lib/testutils/Cargo.toml b/lib/testutils/Cargo.toml index 07429e3983..640806e974 100644 --- a/lib/testutils/Cargo.toml +++ b/lib/testutils/Cargo.toml @@ -20,6 +20,6 @@ config = { workspace = true } git2 = { workspace = true } hex = { workspace = true } itertools = { workspace = true } -jj-lib = { workspace = true } +jj-lib = { workspace = true, features = ["testing"] } rand = { workspace = true } tempfile = { workspace = true } diff --git a/lib/testutils/src/lib.rs b/lib/testutils/src/lib.rs index 25016d911a..1eba76252b 100644 --- a/lib/testutils/src/lib.rs +++ b/lib/testutils/src/lib.rs @@ -32,6 +32,7 @@ use jj_lib::merged_tree::MergedTree; use jj_lib::object_id::ObjectId; use jj_lib::repo::{MutableRepo, ReadonlyRepo, Repo, RepoLoader, StoreFactories}; use jj_lib::repo_path::{RepoPath, RepoPathBuf}; +use jj_lib::secret_backend::SecretBackend; use jj_lib::settings::UserSettings; use jj_lib::signing::Signer; use jj_lib::store::Store; @@ -172,6 +173,12 @@ impl TestRepo { "test", Box::new(|_settings, store_path| Ok(Box::new(TestBackend::load(store_path)))), ); + factories.add_backend( + SecretBackend::name(), + Box::new(|settings, store_path| { + Ok(Box::new(SecretBackend::load(settings, store_path)?)) + }), + ); factories } }