Skip to content

Commit

Permalink
tests: add a strict backend for use in tests
Browse files Browse the repository at this point in the history
We ran into a bug in `MergedTree` with our commit backend at
Google. The problem there was that `MergedTree` sometimes uses the
wrong path when reading files and trees. We didn't catch the bug in
our tests (outside of Google) because both our backends let you read
files and trees at any path.

This commit introduces a stricter backend that we can use in tests to
catch this kind of bug. For simplicity, it stores all data in
memory. Since tests are short-lived, I think that should be fine.

For now, this backend is stricter only in that it doesn't mix objects
written to different paths. We can make it strict/lossy in other ways
later (e.g. modifying written commit objects).

I think having a backend designed for tests can also be useful for
later making it possible to control the backend, e.g. to inject
errors.

We may want to replace almost all uses of the local backend in tests
with uses of this new test backend.
  • Loading branch information
martinvonz committed Sep 18, 2023
1 parent 3f88ce4 commit b908136
Show file tree
Hide file tree
Showing 2 changed files with 282 additions and 0 deletions.
6 changes: 6 additions & 0 deletions lib/testutils/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ use jj_lib::working_copy::{SnapshotError, SnapshotOptions};
use jj_lib::workspace::Workspace;
use tempfile::TempDir;

use crate::test_backend::TestBackend;

pub mod test_backend;

pub fn hermetic_libgit2() {
// libgit2 respects init.defaultBranch (and possibly other config
// variables) in the user's config files. Disable access to them to make
Expand Down Expand Up @@ -88,13 +92,15 @@ pub struct TestRepo {
pub enum TestRepoBackend {
Git,
Local,
Test,
}

impl TestRepoBackend {
fn init_backend(&self, store_path: &Path) -> Result<Box<dyn Backend>, BackendInitError> {
match self {
TestRepoBackend::Git => Ok(Box::new(GitBackend::init_internal(store_path)?)),
TestRepoBackend::Local => Ok(Box::new(LocalBackend::init(store_path))),
TestRepoBackend::Test => Ok(Box::new(TestBackend::init(store_path))),
}
}
}
Expand Down
276 changes: 276 additions & 0 deletions lib/testutils/src/test_backend.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
// Copyright 2023 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 std::any::Any;
use std::collections::HashMap;
use std::fmt::{Debug, Error, Formatter};
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};

use jj_lib::backend::{
make_root_commit, Backend, BackendError, BackendResult, ChangeId, Commit, CommitId, Conflict,
ConflictId, FileId, ObjectId, SymlinkId, Tree, TreeId,
};
use jj_lib::repo_path::RepoPath;

const HASH_LENGTH: usize = 10;
const CHANGE_ID_LENGTH: usize = 16;

static BACKEND_DATA: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<TestBackendData>>>>> =
OnceLock::new();

fn backend_data() -> &'static Mutex<HashMap<PathBuf, Arc<Mutex<TestBackendData>>>> {
BACKEND_DATA.get_or_init(|| Mutex::new(HashMap::new()))
}

#[derive(Default)]
pub struct TestBackendData {
commits: HashMap<CommitId, Commit>,
trees: HashMap<RepoPath, HashMap<TreeId, Tree>>,
files: HashMap<RepoPath, HashMap<FileId, Vec<u8>>>,
symlinks: HashMap<RepoPath, HashMap<SymlinkId, String>>,
conflicts: HashMap<RepoPath, HashMap<ConflictId, Conflict>>,
}

fn test_hash(content: &(impl jj_lib::content_hash::ContentHash + ?Sized)) -> Vec<u8> {
jj_lib::content_hash::blake2b_hash(content).as_slice()[..HASH_LENGTH].to_vec()
}

/// A commit backend for use in tests. It's meant to be strict, in order to
/// catch bugs where we make the wrong assumptions. For example, unlike both
/// `GitBackend` and `LocalBackend`, this backend doesn't share objects written
/// to different paths (writing a file with contents X to path A will not make
/// it possible to read that contents from path B given the same `FileId`).
pub struct TestBackend {
root_commit_id: CommitId,
root_change_id: ChangeId,
empty_tree_id: TreeId,
data: Arc<Mutex<TestBackendData>>,
}

impl TestBackend {
pub fn init(store_path: &Path) -> Self {
let root_commit_id = CommitId::from_bytes(&[0; HASH_LENGTH]);
let root_change_id = ChangeId::from_bytes(&[0; CHANGE_ID_LENGTH]);
let empty_tree_id = TreeId::new(test_hash(&Tree::default()));
let data = Arc::new(Mutex::new(TestBackendData::default()));
backend_data()
.lock()
.unwrap()
.insert(store_path.to_path_buf(), data.clone());
TestBackend {
root_commit_id,
root_change_id,
empty_tree_id,
data,
}
}

pub fn load(store_path: &Path) -> Self {
let data = backend_data()
.lock()
.unwrap()
.get(store_path)
.unwrap()
.clone();
let root_commit_id = CommitId::from_bytes(&[0; HASH_LENGTH]);
let root_change_id = ChangeId::from_bytes(&[0; CHANGE_ID_LENGTH]);
let empty_tree_id = TreeId::new(test_hash(&Tree::default()));
TestBackend {
root_commit_id,
root_change_id,
empty_tree_id,
data,
}
}

fn locked_data(&self) -> MutexGuard<'_, TestBackendData> {
self.data.lock().unwrap()
}
}

impl Debug for TestBackend {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
f.debug_struct("TestBackend").finish_non_exhaustive()
}
}

impl Backend for TestBackend {
fn as_any(&self) -> &dyn Any {
self
}

fn name(&self) -> &str {
"test"
}

fn commit_id_length(&self) -> usize {
HASH_LENGTH
}

fn change_id_length(&self) -> usize {
CHANGE_ID_LENGTH
}

fn root_commit_id(&self) -> &CommitId {
&self.root_commit_id
}

fn root_change_id(&self) -> &ChangeId {
&self.root_change_id
}

fn empty_tree_id(&self) -> &TreeId {
&self.empty_tree_id
}

fn read_file(&self, path: &RepoPath, id: &FileId) -> BackendResult<Box<dyn Read>> {
match self
.locked_data()
.files
.get(path)
.and_then(|items| items.get(id))
.cloned()
{
None => Err(BackendError::ObjectNotFound {
object_type: "file".to_string(),
hash: id.hex(),
source: format!("at path {path:?}").into(),
}),
Some(contents) => Ok(Box::new(Cursor::new(contents))),
}
}

fn write_file(&self, path: &RepoPath, contents: &mut dyn Read) -> BackendResult<FileId> {
let mut bytes = Vec::new();
contents.read_to_end(&mut bytes).unwrap();
let id = FileId::new(test_hash(&bytes));
self.locked_data()
.files
.entry(path.clone())
.or_default()
.insert(id.clone(), bytes);
Ok(id)
}

fn read_symlink(&self, path: &RepoPath, id: &SymlinkId) -> Result<String, BackendError> {
match self
.locked_data()
.symlinks
.get(path)
.and_then(|items| items.get(id))
.cloned()
{
None => Err(BackendError::ObjectNotFound {
object_type: "symlink".to_string(),
hash: id.hex(),
source: format!("at path {path:?}").into(),
}),
Some(target) => Ok(target),
}
}

fn write_symlink(&self, path: &RepoPath, target: &str) -> Result<SymlinkId, BackendError> {
let id = SymlinkId::new(test_hash(target.as_bytes()));
self.locked_data()
.symlinks
.entry(path.clone())
.or_default()
.insert(id.clone(), target.to_string());
Ok(id)
}

fn read_tree(&self, path: &RepoPath, id: &TreeId) -> BackendResult<Tree> {
if id == &self.empty_tree_id {
return Ok(Tree::default());
}
match self
.locked_data()
.trees
.get(path)
.and_then(|items| items.get(id))
.cloned()
{
None => Err(BackendError::ObjectNotFound {
object_type: "tree".to_string(),
hash: id.hex(),
source: format!("at path {path:?}").into(),
}),
Some(tree) => Ok(tree),
}
}

fn write_tree(&self, path: &RepoPath, contents: &Tree) -> BackendResult<TreeId> {
let id = TreeId::new(test_hash(contents));
self.locked_data()
.trees
.entry(path.clone())
.or_default()
.insert(id.clone(), contents.clone());
Ok(id)
}

fn read_conflict(&self, path: &RepoPath, id: &ConflictId) -> BackendResult<Conflict> {
match self
.locked_data()
.conflicts
.get(path)
.and_then(|items| items.get(id))
.cloned()
{
None => Err(BackendError::ObjectNotFound {
object_type: "conflict".to_string(),
hash: id.hex(),
source: format!("at path {path:?}").into(),
}),
Some(conflict) => Ok(conflict),
}
}

fn write_conflict(&self, path: &RepoPath, contents: &Conflict) -> BackendResult<ConflictId> {
let id = ConflictId::new(test_hash(contents));
self.locked_data()
.conflicts
.entry(path.clone())
.or_default()
.insert(id.clone(), contents.clone());
Ok(id)
}

fn read_commit(&self, id: &CommitId) -> BackendResult<Commit> {
if id == &self.root_commit_id {
return Ok(make_root_commit(
self.root_change_id.clone(),
self.empty_tree_id.clone(),
));
}
match self.locked_data().commits.get(id).cloned() {
None => Err(BackendError::ObjectNotFound {
object_type: "commit".to_string(),
hash: id.hex(),
source: "".into(),
}),
Some(commit) => Ok(commit),
}
}

fn write_commit(&self, contents: Commit) -> BackendResult<(CommitId, Commit)> {
let id = CommitId::new(test_hash(&contents));
self.locked_data()
.commits
.insert(id.clone(), contents.clone());
Ok((id, contents))
}
}

0 comments on commit b908136

Please sign in to comment.