Compare commits

...

2 Commits

Author SHA1 Message Date
Anton Tarasenko 2bffa412ea Fix formatting (`cargo fmt`) 4 years ago
Anton Tarasenko 5602ee7c0a Implement database feature 4 years ago
  1. 169
      Cargo.lock
  2. 4
      Cargo.toml
  3. 13
      fixtures/database/administration/registered.json
  4. 12
      fixtures/database/game/general.json
  5. 12
      fixtures/database/game/perks.json
  6. 220
      src/database/file.rs
  7. 240
      src/database/io.rs
  8. 263
      src/database/mod.rs
  9. 380
      src/database/tests.rs
  10. 15
      src/main.rs

169
Cargo.lock generated

@ -1,6 +1,175 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
[[package]]
name = "autocfg"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]] [[package]]
name = "avarice" name = "avarice"
version = "0.1.0" version = "0.1.0"
dependencies = [
"custom_error 1.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_json 1.0.59 (registry+https://github.com/rust-lang/crates.io-index)",
"simplelog 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "cfg-if"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "chrono"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"libc 0.2.80 (registry+https://github.com/rust-lang/crates.io-index)",
"num-integer 0.1.44 (registry+https://github.com/rust-lang/crates.io-index)",
"num-traits 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)",
"time 0.1.44 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "custom_error"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "itoa"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "libc"
version = "0.2.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "log"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num-integer"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"autocfg 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
"num-traits 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "num-traits"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"autocfg 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "ryu"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "serde"
version = "1.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "serde_json"
version = "1.0.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"itoa 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
"ryu 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.117 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "simplelog"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"chrono 0.4.19 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)",
"termcolor 1.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "termcolor"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"winapi-util 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "time"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"libc 0.2.80 (registry+https://github.com/rust-lang/crates.io-index)",
"wasi 0.10.0+wasi-snapshot-preview1 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "wasi"
version = "0.10.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[metadata]
"checksum autocfg 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
"checksum cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)" = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
"checksum chrono 0.4.19 (registry+https://github.com/rust-lang/crates.io-index)" = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
"checksum custom_error 1.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "51ac5e99a7fea3ee8a03fa4721a47e2efd3fbb38358fc61192a54d4c6f866c12"
"checksum itoa 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)" = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6"
"checksum libc 0.2.80 (registry+https://github.com/rust-lang/crates.io-index)" = "4d58d1b70b004888f764dfbf6a26a3b0342a1632d33968e4a179d8011c760614"
"checksum log 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)" = "4fabed175da42fed1fa0746b0ea71f412aa9d35e76e95e59b192c64b9dc2bf8b"
"checksum num-integer 0.1.44 (registry+https://github.com/rust-lang/crates.io-index)" = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
"checksum num-traits 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)" = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
"checksum ryu 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)" = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e"
"checksum serde 1.0.117 (registry+https://github.com/rust-lang/crates.io-index)" = "b88fa983de7720629c9387e9f517353ed404164b1e482c970a90c1a4aaf7dc1a"
"checksum serde_json 1.0.59 (registry+https://github.com/rust-lang/crates.io-index)" = "dcac07dbffa1c65e7f816ab9eba78eb142c6d44410f4eeba1e26e4f5dfa56b95"
"checksum simplelog 0.8.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2b2736f58087298a448859961d3f4a0850b832e72619d75adc69da7993c2cd3c"
"checksum termcolor 1.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4"
"checksum time 0.1.44 (registry+https://github.com/rust-lang/crates.io-index)" = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
"checksum wasi 0.10.0+wasi-snapshot-preview1 (registry+https://github.com/rust-lang/crates.io-index)" = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
"checksum winapi 0.3.9 (registry+https://github.com/rust-lang/crates.io-index)" = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
"checksum winapi-i686-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
"checksum winapi-util 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)" = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
"checksum winapi-x86_64-pc-windows-gnu 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

4
Cargo.toml

@ -7,3 +7,7 @@ edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
simplelog = "0.8"
log = "0.4"
serde_json = "1.0"
custom_error = "1.8.0"

13
fixtures/database/administration/registered.json

@ -0,0 +1,13 @@
{
"76561198025127722": {
"allowed_ips": ["127.0.0.1", "192.168.0.100"],
"groups": ["admin"],
"ip_lock": true,
"password_hash": "fce798e0804dfb217f929bdba26745024f37f6b6ba7406f3775176e20dd5089d"
},
"76561198044316328": {
"groups": ["admin"],
"ip_lock": false,
"password_hash": "fce798e0804dfb217f929bdba26745024f37f6b6ba7406f3775176e20dd5089d"
}
}

12
fixtures/database/game/general.json

@ -0,0 +1,12 @@
{
"76561198025127722": {
"walked": 1073,
"dosh_thrown": 483482,
"achievements": ["kf:LabCleaner", "kf:ChickenFarmer", "scrn:playedscrn"]
},
"76561198044316328": {
"walked": 1693,
"dosh_thrown": 527624,
"achievements": ["kf:PubCrawl", "kf:FascistDietitian", "kf:GimliThatAxe!", "scrn:playedscrn"]
}
}

12
fixtures/database/game/perks.json

@ -0,0 +1,12 @@
{
"76561198025127722": {
"headshots": 582,
"assault_rifle_damage": 9067,
"stalker_kills": 143
},
"76561198044316328": {
"explosive_damage": 19674,
"shotgun_damage": 3835,
"welded_amount": 1
}
}

220
src/database/file.rs

@ -0,0 +1,220 @@
use serde_json;
use serde_json::json;
use std::error::Error;
extern crate custom_error;
use custom_error::custom_error;
const JSON_POINTER_SEPARATOR: &str = "/";
custom_error! { pub IncorrectPointer{pointer: String} = "Incorrect pointer is specified: {pointer}" }
/// This is a enum that used internally to refer to values inside of
/// JSON (their serde implementation) objects and arrays.
/// This enum helps to simplify module's code.
///
/// For values inside of JSON object it stores object's
/// `Map<String, serde_json::Value>` and name of referred value.
///
/// For values inside JSON arrays it stores array's
/// `Vec<serde_json::Value>` and referred index.
///
/// `Invalid` can be used to return a failed state.
enum ValueReference<'a> {
Object(&'a mut serde_json::Map<String, serde_json::Value>, String),
Array(&'a mut Vec<serde_json::Value>, usize),
Invalid,
}
/// Implements database's file by wrapping JSON value (`serde_json::Value`)
/// and providing several convenient accessor methods.
#[derive(Debug)]
pub struct File {
/// File's full contents, normally a JSON object.
contents: serde_json::Value,
}
impl ToString for File {
fn to_string(&self) -> String {
self.contents.to_string()
}
}
impl File {
/// Creates an empty file that will contain an empty JSON object.
pub fn empty() -> File {
File {
contents: json!({}),
}
}
/// Loads JSON value from the specified file.
pub fn load(file_contents: String) -> Result<File, Box<dyn Error>> {
Ok(File {
contents: serde_json::from_str(&file_contents)?,
})
}
/// Returns file's "root", - JSON value contained inside it.
pub fn root(&self) -> &serde_json::Value {
&self.contents
}
/// Attempts to return JSON value, corresponding to the given JSON pointer.
/// `None` if the value is missing.
pub fn get(&self, pointer: &str) -> Option<&serde_json::Value> {
self.contents.pointer(pointer)
}
/// Checks if values at a given JSON pointer exists.
/// Returns `true` if it does.
pub fn contains(&self, pointer: &str) -> bool {
self.get(pointer) != None
}
/// Inserts new JSON value inside this file
/// (possibly in some sub-object/array).
///
/// Given pointer must point at new value:
/// 1. If it already exists, - it will be overwritten.
/// 2. If it does not exist, but it's parent object/array does -
/// it will be added.
/// 3. Otherwise an error will be raise.
///
/// If array needs to be expanded, - missing values will be filled
/// with `json!(null)`, i.e. inserting `7` at index `5` in array `[1, 2, 3]`
/// will produce `[1, 2, 3, null, null, 7]`.
pub fn insert(
&mut self,
pointer: &str,
new_value: serde_json::Value,
) -> Result<(), IncorrectPointer> {
self.touch(pointer)?;
match self.contents.pointer_mut(pointer) {
Some(v) => *v = new_value,
_ => {
// If after `touch()` call we still don't have an existing value -
// something is wrong with the `pointer`
return Err(IncorrectPointer {
pointer: pointer.to_owned(),
});
}
};
Ok(())
}
/// Removes (and returns) value specified by theJSON pointer.
/// If it did not exist - returns `None`.
pub fn remove(&mut self, pointer: &str) -> Option<serde_json::Value> {
match self.pointer_to_reference(pointer) {
ValueReference::Object(map, variable_name) => map.remove(&variable_name),
ValueReference::Array(vec, variable_index) => {
if variable_index < vec.len() {
return Some(vec.remove(variable_index));
}
None
}
_ => None,
}
}
/// Helper method to create a value if missing (as `json!(null)`).
/// Can only be done if parent container already exists.
///
/// For specifics refer to `insert()` method.
fn touch(&mut self, pointer: &str) -> Result<(), IncorrectPointer> {
// If value is present - we're done
if pointer.is_empty() || self.contents.pointer_mut(pointer).is_some() {
return Ok(());
}
// Otherwise - try to create it
match self.pointer_to_reference(pointer) {
ValueReference::Object(map, variable_name) => {
map.insert(variable_name, json!(null));
}
ValueReference::Array(vec, variable_index) => {
// We've checked at the beginning of this method that value
// at `variable_index` does not exist, which guarantees
// that array is to short and we won't shrink it
vec.resize(variable_index + 1, json!(null));
}
_ => {
return Err(IncorrectPointer {
pointer: pointer.to_owned(),
})
}
};
Ok(())
}
/// Helper method, - converts JSON pointer into auxiliary `ValueReference` enum.
fn pointer_to_reference<'a>(&'a mut self, pointer: &str) -> ValueReference<'a> {
if pointer.is_empty() {
return ValueReference::Invalid;
}
// Extract variable name (that `pointer` points to)
// and reference to it's container
//
// i.e. given file with '{"obj":{"arr":[1,3,5,2,4]}}',
// for pointer `/obj/arr/5`,
// it will return, basically, `(&[1,3,5,2,4], "5")`
let container_variable_pair =
pop_json_pointer(pointer).and_then(move |(path, variable_name)| {
match self.contents.pointer_mut(&path) {
Some(v) => Some((v, variable_name)),
_ => None,
}
});
let (json_container, variable_name) = match container_variable_pair {
Some(v) => v,
_ => return ValueReference::Invalid,
};
// For arrays we also need to confirm validity of the variable name
// and convert it into `usize`
match json_container {
serde_json::Value::Object(map) => ValueReference::Object(map, variable_name),
serde_json::Value::Array(vec) => {
let index: usize = match variable_name.parse() {
Ok(v) => v,
_ => return ValueReference::Invalid,
};
ValueReference::Array(vec, index)
}
_ => ValueReference::Invalid,
}
}
}
// Helper function to disassemble JSON path.
fn pop_json_pointer(pointer: &str) -> Option<(String, String)> {
let mut pointer = pointer.to_string();
let last_separator_index = match pointer.rfind(JSON_POINTER_SEPARATOR) {
Some(v) => v,
_ => {
return None;
}
};
if last_separator_index >= pointer.len() {
pointer.pop();
return Some((pointer, String::new()));
}
let var_name = pointer.split_off(last_separator_index + 1);
pointer.pop();
Some((pointer, var_name))
}
#[test]
fn test_pop_json_pointer() {
assert_eq!(
pop_json_pointer("/a/b/c/d"),
Some(("/a/b/c".to_owned(), "d".to_owned()))
);
assert_eq!(pop_json_pointer("/"), Some(("".to_owned(), "".to_owned())));
assert_eq!(
pop_json_pointer("/a/b/"),
Some(("/a/b".to_owned(), "".to_owned()))
);
assert_eq!(pop_json_pointer(""), None);
// This pointer is incorrect
assert_eq!(pop_json_pointer("var"), None);
}

240
src/database/io.rs

@ -0,0 +1,240 @@
use super::*;
use log::{error, info, warn};
use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::path;
use std::path::Path;
extern crate custom_error;
use custom_error::custom_error;
const JSON_EXTENSION: &str = "json";
custom_error! { pub IOError
NotDirectory{path: String} = "Path to the database should point at a directory: {path}",
}
/// Reads database data from a directory at the specified path
/// as a vector of `Group`s.
pub fn read(db_path: &path::Path) -> Result<Vec<Group>, Box<dyn Error>> {
if !db_path.is_dir() {
error!(
"Loading database from a non-directory {} was attempted.",
db_path.display()
);
return Err(Box::new(IOError::NotDirectory {
path: db_path.display().to_string(),
}));
}
info!("Loading database from {}.", db_path.display());
let mut groups = Vec::new();
for entry in fs::read_dir(db_path)? {
let path = match entry {
Ok(r) => r,
_ => continue,
}
.path();
if !check_valid_group_dir(path.as_path()) {
continue;
}
match read_group(&path)? {
Some(g) => groups.push(g),
_ => (),
}
}
info!("Correctly finished leading database.");
Ok(groups)
}
/// Writes data of the given database into the directory specified by the path.
/// Does not clear it from any previously existing files,
/// you can use `clear_dir()` for that.
pub fn write(db_path: &path::Path, db: &Database) -> Result<(), Box<dyn Error>> {
if db_path.exists() && !db_path.is_dir() {
error!(
"Cannot write database into a non-directory {}",
db_path.display()
);
return Err(Box::new(IOError::NotDirectory {
path: db_path.display().to_string(),
}));
}
fs::create_dir(db_path)?;
for group in db.group_names().iter() {
let group_path = db_path.join(group);
if !group_path.exists() || !group_path.is_dir() {
fs::create_dir(group_path.clone())?;
}
for file in db.file_names_in(group).iter() {
let file_path = group_path.join(format!("{}.{}", file, JSON_EXTENSION));
match db.file(group, file) {
Some(file) => fs::write(file_path, file.to_string())?,
_ => (),
}
}
}
Ok(())
}
/// Given a path to directory that was used for database storage -
/// clears it's data.
///
/// This means removing any '.json' files from all immediate subdirectories and
/// then removing any subdirectories that were or became empty.
pub fn clear_dir(db_path: &path::Path) -> Result<(), Box<dyn Error>> {
info!(
"Clearing directory {} from database files.",
db_path.display()
);
if !db_path.exists() {
info!("Directory not found, nothing to do.");
return Ok(());
}
for entry in fs::read_dir(db_path)? {
let dir_path = match entry {
Ok(r) => r,
_ => continue,
}
.path();
if !check_valid_group_dir(dir_path.as_path()) {
continue;
}
for entry in fs::read_dir(dir_path.clone())? {
let file_path = match entry {
Ok(r) => r,
_ => continue,
}
.path();
if !check_valid_data_file(file_path.as_path()) {
continue;
}
fs::remove_file(file_path)?;
}
let _ = fs::remove_dir(dir_path);
}
let _ = fs::remove_dir(db_path);
info!("Correctly finished clearing database files.");
Ok(())
}
/// helper function to read a group from a given subdirectory:
/// loads data from all containing '.json' files.
fn read_group(group_path: &path::Path) -> Result<Option<Group>, Box<dyn Error>> {
let mut files = HashMap::new();
for entry in fs::read_dir(group_path)? {
let path = match entry {
Ok(r) => r,
_ => continue,
}
.path();
if !check_valid_data_file(path.as_path()) {
continue;
}
let file_name = get_file_name(path.as_path());
let file_contents = fs::read_to_string(&path)?;
files.insert(file_name, File::load(file_contents)?);
}
if files.len() > 0 {
return Ok(Some(Group {
name: get_file_name(group_path),
files,
}));
}
Ok(None)
}
/// Checks if given path points at a directory that can represent
/// a database group (has a valid name).
fn check_valid_group_dir(dir_path: &path::Path) -> bool {
if !dir_path.is_dir() {
warn!(
r#"Skipping {}, because only directories are expected in database's root."#,
dir_path.display()
);
return false;
}
if !is_name_valid(&get_file_name(dir_path)) {
warn!(
r#"Skipping directory {}, because it does not have a valid name."#,
dir_path.display()
);
return false;
}
true
}
/// Checks if given path points at a file that can represent
/// a database group (has a valid name).
fn check_valid_data_file(file_path: &path::Path) -> bool {
if file_path.is_dir() {
warn!(
r#"Skipping directory {}, because group directories are only\
supposed to contain files."#,
file_path.display()
);
return false;
}
let name = get_file_name(file_path);
if !is_name_valid(&name) {
warn!(
r#"Skipping file {}, because it does not have a valid name."#,
file_path.display()
);
return false;
}
let extension = get_file_extension(file_path);
if !JSON_EXTENSION.eq_ignore_ascii_case(&extension) {
warn!(
r#"Skipping file {}, because it does not have "json" extension."#,
file_path.display()
);
return false;
}
true
}
fn get_file_name(path: &path::Path) -> String {
path.file_stem()
.and_then(|x| x.to_str())
.unwrap_or_default()
.to_string()
}
fn get_file_extension(path: &path::Path) -> String {
path.extension()
.and_then(|x| x.to_str())
.unwrap_or_default()
.to_string()
}
#[test]
fn test_file_name_extension_extraction() {
assert_eq!(get_file_name(Path::new("/dir/file")), "file".to_owned());
assert_eq!(
get_file_name(Path::new("/dir/sub_dir/some.ext")),
"some".to_owned()
);
assert_eq!(
get_file_name(Path::new("/dir/sub_dir/.ext")),
".ext".to_owned()
);
assert_eq!(
get_file_name(Path::new("/dir/sub_dir/thing.")),
"thing".to_owned()
);
assert_eq!(get_file_extension(Path::new("/dir/file")), "".to_owned());
assert_eq!(
get_file_extension(Path::new("/dir/sub_dir/some.ext")),
"ext".to_owned()
);
assert_eq!(
get_file_extension(Path::new("/dir/sub_dir/.ext")),
"".to_owned()
);
assert_eq!(
get_file_extension(Path::new("/dir/sub_dir/thing.")),
"".to_owned()
);
}

263
src/database/mod.rs

@ -0,0 +1,263 @@
#[cfg(test)]
mod tests;
use serde_json;
use std::collections::HashMap;
use std::error::Error;
use std::path;
extern crate custom_error;
use custom_error::custom_error;
pub mod file;
pub use file::File;
pub mod io;
custom_error! { pub DBError
InvalidEntityName{entity_name: String} = r#"Cannot use {entity_name} for file or group"#,
NoGroup{group_name: String} = r#"Group "{group_name}" does not exist"#,
NoFile{group_name: String, file_name: String} = r#"There is no "{file_name}" file in group\
"{group_name}""#,
GroupAlreadyExists{group_name: String} = r#"Group "{group_name}" already exists"#,
FileAlreadyExists{group_name: String, file_name: String} = r#"File "{file_name}" already exists\
in the group "{group_name}""#,
}
/// Avarice database is a collection of named JSON values (by default objects),
/// separated into different names groups. Names of such groups and JSON values must only contain
/// numbers and latin letters (ASCII subset).
///
/// This database is only supposed to hold a relatively small amount of data
/// that:
///
/// 1. can be freely and full loaded into memory;
/// 2. then saved all at once.
///
/// Database is loaded and saved on the disk as a directory,
/// that contains subdirectories (with valid names) for each group;
/// those subdirectories in turn must contain "*.json" files (with valid names)
/// that correspond to the stored JSON values.
///
/// Database directory should not contain any other files, but their presence
/// should not prevent database from loading (such files should be ignored).
pub struct Database {
/// Path to the database's directory
storage_path: path::PathBuf,
/// Collection of groups (of JSON values) inside of database
groups: Vec<Group>,
}
/// Represents a database's group: a ste of named files
pub struct Group {
/// Name of the group
name: String,
/// Maps file names with their contents (as file::File structures)
files: HashMap<String, File>,
}
impl Database {
/// Creates new database by loading it from the specified directory.
/// Directory must contain valid database and be readable.
pub fn load(storage_path: &path::Path) -> Result<Database, Box<dyn Error>> {
Ok(Database {
storage_path: storage_path.to_path_buf(),
groups: io::read(storage_path)?,
})
}
/// Removes all data from the database.
pub fn clear(&mut self) {
self.groups = Vec::new();
}
/// Returns path from which this database was loaded (can be changed with `change_path()`).
pub fn path(&mut self) -> path::PathBuf {
self.storage_path.clone()
}
/// Changes current path of this database. All operations with files will use this path,
/// unless stated otherwise.
/// Directory must contain valid database and be readable.
///
/// This method will also remove all data from the current database's path
/// and fail if it can't.
pub fn change_path(&mut self, new_path: &path::Path) -> Result<(), Box<dyn Error>> {
self.write_copy(new_path)?;
io::clear_dir(&self.storage_path)?;
self.storage_path = new_path.to_path_buf();
Ok(())
}
/// Erases database's data on disk.
pub fn erase(self) -> Result<(), Box<dyn Error>> {
io::clear_dir(&self.storage_path)?;
Ok(())
}
/// Writes copy of the current database into specified directory.
/// Erases any preexisting database files.
///
/// Empty group won't be saved.
pub fn write_copy(&self, new_path: &path::Path) -> Result<(), Box<dyn Error>> {
io::clear_dir(new_path)?;
io::write(new_path, &self)?;
Ok(())
}
/// Writes current state of the database on the disk.
///
/// Empty group won't be saved.
pub fn save(&self) -> Result<(), Box<dyn Error>> {
self.write_copy(&self.storage_path)?;
Ok(())
}
/// Returns names of all the groups in the database.
pub fn group_names(&self) -> Vec<String> {
self.groups.iter().map(|x| x.name.clone()).collect()
}
/// Returns names of all the files in a particular group.
pub fn file_names_in(&self, group_name: &str) -> Vec<String> {
match self.groups.iter().find(|x| x.name.eq(group_name)) {
Some(group) => group.files.keys().map(|x| x.clone()).collect(),
None => Vec::new(),
}
}
/// Checks if specified group exists in the database
pub fn contains_group(&self, group_name: &str) -> bool {
self.group_index(group_name).is_ok()
}
/// Creates a new empty group.
/// Will produce error if group already exists.
pub fn create_group(&mut self, group_name: &str) -> Result<(), DBError> {
assert_name_is_valid(group_name)?;
self.assert_no_group(group_name)?;
self.groups.push(Group {
name: group_name.to_owned(),
files: HashMap::new(),
});
Ok(())
}
/// Removes specified group.
/// Will produce error if group does not exist.
pub fn remove_group(&mut self, group_name: &str) -> Result<(), DBError> {
let index = self.group_index(group_name)?;
self.groups.remove(index);
Ok(())
}
/// Checks if specified file (in a specified group) is contained in the database.
pub fn contains_file(&self, group_name: &str, file_name: &str) -> bool {
self.group_files(group_name)
.and_then(|x| Ok(x.contains_key(&file_name.to_owned())))
.unwrap_or(false)
}
/// Creates new file in the specified group that will contain an empty JSON object.
/// Will produce error if file already exists.
pub fn create_file(&mut self, group_name: &str, file_name: &str) -> Result<&mut File, DBError> {
assert_name_is_valid(&file_name)?;
let files = self.group_files_mut(group_name)?;
if files.contains_key(&file_name.to_owned()) {
return Err(DBError::FileAlreadyExists {
group_name: group_name.to_owned(),
file_name: file_name.to_owned(),
});
}
let new_file = File::empty();
files.insert(file_name.to_owned(), new_file);
Ok(files
.get_mut(file_name)
.expect("Missing value that was just inserted."))
}
/// Removes specified file (in a specified group).
/// Will produce error if file does not exist.
pub fn remove_file(&mut self, group_name: &str, file_name: &str) -> Result<(), DBError> {
if self
.group_files_mut(group_name)?
.remove(file_name)
.is_none()
{
return Err(DBError::NoFile {
group_name: group_name.to_owned(),
file_name: file_name.to_owned(),
});
}
Ok(())
}
/// Returns immutable reference to the specified file (in a specified group) as `file::File`.
/// `None` if file does not exist.
pub fn file_mut(&mut self, group_name: &str, file_name: &str) -> Option<&mut File> {
match self.group_files_mut(group_name) {
Ok(files) => files.get_mut(&file_name.to_owned()),
_ => None,
}
}
/// Returns mutable reference to the specified file (in a specified group) as `file::File`.
/// `None` if file does not exist.
pub fn file(&self, group_name: &str, file_name: &str) -> Option<&File> {
match self.group_files(group_name) {
Ok(files) => files.get(&file_name.to_owned()),
_ => None,
}
}
/// Helper method that raises error if specified group exists.
fn assert_no_group(&self, group_name: &str) -> Result<(), DBError> {
if self.group_index(group_name).is_ok() {
return Err(DBError::GroupAlreadyExists {
group_name: group_name.to_owned(),
});
}
Ok(())
}
/// Returns current index of the specified group in`groups` vector.
fn group_index(&self, group_name: &str) -> Result<usize, DBError> {
match self.groups.iter().position(|x| x.name.eq(group_name)) {
Some(index) => Ok(index),
_ => {
return Err(DBError::NoGroup {
group_name: group_name.to_owned(),
})
}
}
}
// Helper methods that return (im)mutable reference to `HashMap`
// (of 'file_name -> file' map) for a particular group.
fn group_files_mut(&mut self, group_name: &str) -> Result<&mut HashMap<String, File>, DBError> {
let group_index = self.group_index(group_name)?;
Ok(&mut (&mut self.groups[group_index]).files)
}
fn group_files(&self, group_name: &str) -> Result<&HashMap<String, File>, DBError> {
let group_index = self.group_index(group_name)?;
Ok(&self.groups[group_index].files)
}
}
/// Name validity check (for groups and files)
fn is_name_valid(entity_name: &str) -> bool {
entity_name
.chars()
.all(|x| x.is_ascii_alphabetic() || x.is_ascii_digit())
}
/// Helper function that raises error if passed name is invalid
fn assert_name_is_valid(entity_name: &str) -> Result<(), DBError> {
if is_name_valid(entity_name) {
return Ok(());
}
return Err(DBError::InvalidEntityName {
entity_name: entity_name.to_owned(),
});
}

380
src/database/tests.rs

@ -0,0 +1,380 @@
use super::*;
use serde_json::json;
use std::fs;
use std::path;
const TEST_DB_PATH: &str = "./fixtures/database";
const TEST_DB_MOVED_PATH: &str = "./fixtures/moved";
const NO_DB_MESSAGE: &str = "Can not find/load test database";
struct TestCleanup {
path: String,
clear_moved: bool,
}
impl Drop for TestCleanup {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
if self.clear_moved {
let _ = fs::remove_dir_all(TEST_DB_MOVED_PATH);
}
}
}
fn prepare_db_copy(copy_id: &str, clear_moved: bool) -> (String, TestCleanup) {
let path = format!("{}_{}", TEST_DB_PATH, copy_id);
let original_db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
original_db
.write_copy(path::Path::new(&path))
.expect("Should be able to create a new copy of the fixture database.");
(
path.clone(),
TestCleanup {
path: path.to_owned(),
clear_moved: clear_moved,
},
)
}
#[test]
fn db_path() {
let mut db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
assert!(db.path() == path::Path::new(TEST_DB_PATH));
}
#[test]
fn db_clear() {
let mut db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
db.clear();
assert!(!db.contains_group("game"));
assert!(!db.contains_file("administration", "registered"));
let names = db.group_names();
assert_eq!(names.len(), 0);
}
#[test]
fn db_group_names() {
let db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
let names = db.group_names();
assert!(names.contains(&"administration".to_owned()));
assert!(names.contains(&"game".to_owned()));
assert_eq!(names.len(), 2);
}
#[test]
fn db_file_names() {
let db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
let names_admin = db.file_names_in("administration");
let names_game = db.file_names_in("game");
assert!(names_admin.contains(&"registered".to_owned()));
assert!(names_game.contains(&"general".to_owned()));
assert!(names_game.contains(&"perks".to_owned()));
assert_eq!(names_admin.len(), 1);
assert_eq!(names_game.len(), 2);
}
#[test]
fn db_group_check() {
let db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
assert!(db.contains_group("game"));
assert!(db.contains_group("administration"));
assert!(!db.contains_group("perks"));
assert!(!db.contains_group("7random7"));
assert!(!db.contains_group(""));
}
#[test]
fn db_group_remove() -> Result<(), Box<dyn Error>> {
let mut db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
// Success
db.remove_group("administration")?;
db.remove_group("game")?;
assert!(!db.contains_group("administration"));
assert!(!db.contains_group("game"));
// Failure
db.remove_group("test")
.expect_err("Testing whether removing non-existent groups with incorrect ASCII characters causes errors.");
db.remove_group("administration")
.expect_err("Testing whether removing non-existent groups with incorrect ASCII characters causes errors.");
db.remove_group("game group").expect_err(
"Testing whether removing non-existent groups with whitespace characters causes errors.",
);
Ok(())
}
#[test]
fn db_group_create() -> Result<(), Box<dyn Error>> {
let mut db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
// Success
db.create_group("7random7")?;
assert!(db.contains_group("7random7"));
assert!(db.contains_group("game"));
// Failure
db.create_group("my_group").expect_err(
"Testing whether creating groups with incorrect ASCII characters causes errors.",
);
db.create_group("my group")
.expect_err("Testing whether creating groups with whitespace characters causes errors.");
db.create_group("Жgroup")
.expect_err("Testing whether creating groups with non-ASCII characters causes errors.");
// Create after removal
db.remove_group("game")?;
assert!(!db.contains_group("game"));
db.create_group("game")?;
assert!(db.contains_group("game"));
assert!(db.file_names_in("game").is_empty());
Ok(())
}
#[test]
fn db_file_check() {
let mut db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
// Success
assert!(db.contains_file("administration", "registered"));
assert!(db.file("game", "general").is_some());
assert!(db.file_mut("game", "perks").is_some());
// Failure
assert!(!db.contains_file("game", "perk"));
assert!(db.file("games", "perks").is_none());
assert!(db.file_mut("random", "rnd_file").is_none());
}
#[test]
fn db_file_create() -> Result<(), Box<dyn Error>> {
let mut db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
// Success
let file = db.create_file("administration", "secrets")?;
assert_eq!(file.to_string(), "{}");
assert!(db.contains_file("administration", "secrets"));
assert!(db.contains_file("game", "perks"));
// Failure
db.create_file("administration", "secrets")
.expect_err("Testing whether creating existing file causes errors.");
db.create_file("none", "secrets")
.expect_err("Testing whether creating existing file in non-existent group causes errors.");
db.create_file("game", "sec_rets")
.expect_err("Testing whether creating existing file with invalid name causes errors.");
Ok(())
}
#[test]
fn db_file_remove() -> Result<(), Box<dyn Error>> {
let mut db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
// Success
db.remove_file("administration", "registered")?;
assert!(!db.contains_file("administration", "registered"));
assert!(db.contains_group("administration"));
db.remove_file("game", "perks")?;
assert_eq!(db.file_names_in("game").len(), 1);
// Failure
db.remove_file("administration", "registered")
.expect_err("Testing whether removing non-existent files causes errors.");
db.remove_file("administration", "never")
.expect_err("Testing whether removing non-existent files causes errors.");
db.remove_file("never", "file")
.expect_err("Testing whether removing non-existent files causes errors.");
Ok(())
}
#[test]
fn file_json_contents() {
let db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
let registered = db.file("administration", "registered").unwrap().root();
let user_map = registered
.as_object()
.expect("Read value is not an object.");
assert_eq!(user_map.len(), 2);
assert!(user_map.contains_key("76561198025127722"));
let user_record = user_map
.get("76561198044316328")
.unwrap()
.as_object()
.unwrap();
assert_eq!(user_record.len(), 3);
assert_eq!(user_record.get("ip_lock").unwrap().as_bool(), Some(false));
assert_eq!(
user_record.get("password_hash").unwrap().as_str(),
Some("fce798e0804dfb217f929bdba26745024f37f6b6ba7406f3775176e20dd5089d")
);
let groups_arrays = user_record.get("groups").unwrap().as_array().unwrap();
assert_eq!(groups_arrays.len(), 1);
assert_eq!(groups_arrays[0], "admin");
}
#[test]
fn file_json_get() {
let db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
// Test empty path
let file = db.file("administration", "registered").unwrap();
assert_eq!(file.root(), file.get("").unwrap());
// Test complex path
let expected = file.get("/76561198025127722/allowed_ips/1").unwrap();
assert_eq!(expected.as_str().unwrap(), "192.168.0.100");
// Test bad paths
assert!(file.get("/777") == None);
assert!(file.get("/76561198025127722/allowed_ips/2") == None);
}
#[test]
fn file_contains_check() {
let db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
let registered_file = db.file("administration", "registered").unwrap();
let perks_file = db.file("game", "perks").unwrap();
// These exist
assert!(registered_file.contains("/76561198025127722/password_hash"));
assert!(registered_file.contains("/76561198044316328/groups/0"));
assert!(perks_file.contains("/76561198025127722/headshots"));
assert!(perks_file.contains("/76561198044316328"));
// These do not exist
assert!(!registered_file.contains("/76561198025127722/password/"));
assert!(!registered_file.contains("/76561198044316328/groups/2"));
assert!(!perks_file.contains("/76561198025127722/assault_rifle_damage/9067"));
assert!(!perks_file.contains("/76561198044316328/headshots"));
}
#[test]
fn db_insert_success() -> Result<(), Box<dyn Error>> {
let mut db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
let registered_file = db.file_mut("administration", "registered").unwrap();
// Modify existing
registered_file.insert("/76561198025127722/ip_lock", json!(false))?;
assert_eq!(
registered_file
.get("/76561198025127722/ip_lock")
.unwrap()
.to_string(),
"false"
);
registered_file.insert("/76561198044316328/password_hash", json!({"var":13524}))?;
assert_eq!(
registered_file
.get("/76561198044316328/password_hash")
.unwrap()
.to_string(),
r#"{"var":13524}"#
);
// Reset whole file
registered_file.insert("", json!({}))?;
assert_eq!(registered_file.root().to_string(), "{}");
// Add new values
registered_file.insert("/new_var", json!([42, {"word":"life"}, null]))?;
assert_eq!(
registered_file.root().to_string(),
r#"{"new_var":[42,{"word":"life"},null]}"#
);
let general_file = db.file_mut("game", "general").unwrap();
general_file.insert("/76561198025127722/achievements/5", json!("kf:bugged"))?;
assert_eq!(
general_file
.get("/76561198025127722/achievements")
.unwrap()
.to_string(),
r#"["kf:LabCleaner","kf:ChickenFarmer","scrn:playedscrn",null,null,"kf:bugged"]"#
);
Ok(())
}
#[test]
fn db_set_failure() {
let mut db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
let file = db.file_mut("administration", "registered").unwrap();
file.insert("/76561198025127722/dir/var", json!(null))
.expect_err("Testing panic at trying to set a value in non-existing object/array.");
file.insert("/76561198044316328/groups/d", json!(null))
.expect_err("Testing panic at trying to set a value at non-numeric index in an array.");
file.insert("/76561198044316328/groups/-1", json!(null))
.expect_err("Testing panic at trying to set a value at negative index in an array.");
}
#[test]
fn db_remove_value() {
let mut db = Database::load(path::Path::new(TEST_DB_PATH)).expect(NO_DB_MESSAGE);
let file = db.file_mut("administration", "registered").unwrap();
// Removing non-existent value
assert_eq!(file.remove("/76561198025127722/something"), None);
// Remove simple value
assert_eq!(
file.remove("/76561198025127722/password_hash").unwrap(),
json!("fce798e0804dfb217f929bdba26745024f37f6b6ba7406f3775176e20dd5089d")
);
assert!(!file.contains("/76561198025127722/password_hash"));
// Remove complex value (array)
assert_eq!(
file.remove("/76561198044316328/groups").unwrap(),
json!(["admin"])
);
assert!(!file.contains("/76561198044316328/groups/0"));
assert!(!file.contains("/76561198044316328/groups"));
// Remove array elements
assert_eq!(
file.remove("/76561198025127722/allowed_ips/0").unwrap(),
json!("127.0.0.1")
);
assert!(file.contains("/76561198025127722/allowed_ips/0"));
assert!(!file.contains("/76561198025127722/allowed_ips/1"));
assert_eq!(
file.remove("/76561198025127722/allowed_ips/0").unwrap(),
json!("192.168.0.100")
);
assert!(file.contains("/76561198025127722/allowed_ips"));
assert!(!file.contains("/76561198025127722/allowed_ips/0"));
}
#[test]
fn db_save() {
let (path, _cleanup) = prepare_db_copy("db_save", false);
// Change something up and save
let mut db = Database::load(path::Path::new(&path)).expect(NO_DB_MESSAGE);
db.remove_group("administration")
.expect(r#"Should be able to remove "administration" group"#);
db.save()
.expect("Should be able to save copy of the database.");
// Reload and check changes
let db = Database::load(path::Path::new(&path)).expect(NO_DB_MESSAGE);
assert_eq!(db.group_names().len(), 1);
assert_eq!(db.group_names().get(0), Some(&"game".to_owned()));
assert_eq!(db.file_names_in("game").len(), 2);
assert!(db.contains_file("game", "general"));
assert!(db.contains_file("game", "perks"));
}
#[test]
fn db_change_path() {
let (path, _cleanup) = prepare_db_copy("db_change_path", true);
// Change something up and move
let mut db = Database::load(path::Path::new(&path)).expect(NO_DB_MESSAGE);
db.remove_group("administration")
.expect(r#"Should be able to remove "administration" group"#);
db.file_mut("game", "perks")
.unwrap()
.insert("", json!({"var":7}))
.expect("Should be able to insert into root.");
db.change_path(path::Path::new(TEST_DB_MOVED_PATH))
.expect("Should be able to change database's path.");
assert!(!path::Path::new(&path).exists());
assert!(path::Path::new(TEST_DB_MOVED_PATH).exists());
// Reload and check the changes
let db = Database::load(path::Path::new(TEST_DB_MOVED_PATH)).expect(NO_DB_MESSAGE);
assert_eq!(db.group_names().len(), 1);
assert_eq!(db.group_names().get(0), Some(&"game".to_owned()));
assert_eq!(db.file_names_in("game").len(), 2);
assert!(db.contains_file("game", "general"));
assert!(db.contains_file("game", "perks"));
assert_eq!(
db.file("game", "perks").unwrap().root().to_string(),
r#"{"var":7}"#.to_owned()
);
}
#[test]
fn db_erase() {
let (path, _cleanup) = prepare_db_copy("db_erase", false);
let db = Database::load(path::Path::new(&path)).expect(NO_DB_MESSAGE);
db.erase().expect("Should be able to erase data.");
assert!(!path::Path::new(&path).exists());
}

15
src/main.rs

@ -1,13 +1,16 @@
use std::env; use std::env;
use std::path::Path; use std::path::Path;
mod unreal_config; mod database;
use simplelog::{Config, LevelFilter, SimpleLogger};
fn main() { fn main() {
let _ = SimpleLogger::init(LevelFilter::Info, Config::default());
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
let filename = &args[1]; let filename = &args[1];
let config = unreal_config::load_file(Path::new(filename)); let db = database::Database::load(Path::new(filename));
match config { /*match db {
Ok(config) => print!("{}", config), Ok(db) => print!("{}", db),
_ => (), Err(error) => println!("OH NO: {}", error),
} }*/
} }

Loading…
Cancel
Save