use std::fmt::Display;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use simd_json::{
borrowed::{self, Value},
derived::{ValueTryAsScalar, ValueTryIntoObject, ValueTryIntoString},
owned,
value::prelude::base::Writable,
};
use super::{
Entity,
id::{Id, entity_id::EntityId},
identity::IdentityStub,
nonce::Nonce,
timestamp::TimeStamp,
};
use crate::replica::entity::operation::operation_data::OperationData;
pub mod operation_data;
pub mod operation_pack;
pub mod operations;
#[allow(clippy::unsafe_derive_deserialize)]
#[derive(Debug, Deserialize, Serialize)]
pub struct Operation<E: Entity> {
pub(super) author: IdentityStub,
pub(super) creation_time: TimeStamp,
pub(super) metadata: Option<Vec<(String, String)>>,
nonce: Nonce,
#[serde(bound = "EntityId<E>: serde::Serialize + serde::de::DeserializeOwned")]
id: EntityId<E>,
#[serde(bound = "E::OperationData: serde::Serialize + serde::de::DeserializeOwned")]
pub(super) data: E::OperationData,
}
impl<E: Entity> Display for Operation<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
<Self as std::fmt::Debug>::fmt(self, f)
}
}
impl<E: Entity> Operation<E> {
pub fn author(&self) -> IdentityStub {
self.author
}
pub fn operation_data(&self) -> &E::OperationData {
&self.data
}
pub fn creation_time(&self) -> TimeStamp {
self.creation_time
}
pub fn metadata(&self) -> impl Iterator<Item = &(String, String)> {
self.metadata.iter().flat_map(|a| a.iter())
}
pub fn as_value(&self) -> borrowed::Object<'_> {
Self::as_value_parts(
&self.data,
unsafe { self.creation_time.to_unsafe() }.value,
self.nonce,
self.metadata.as_ref(),
)
}
fn as_value_parts<'a>(
data: &'a E::OperationData,
creation_time: u64,
nonce: Nonce,
metadata: Option<&'a Vec<(String, String)>>,
) -> borrowed::Object<'a> {
let mut object = borrowed::Object::new();
unsafe {
object.insert_nocheck("type".into(), data.to_json_type().into());
object.insert_nocheck("timestamp".into(), creation_time.into());
object.insert_nocheck("nonce".into(), Into::<String>::into(nonce).into());
}
if let Some(meta) = metadata {
let mut metadata = borrowed::Object::new();
for (k, v) in meta {
assert_eq!(
metadata.insert(k.into(), v.as_str().into()),
None,
"No duplicate name expected"
);
}
unsafe {
object.insert_nocheck("metadata".into(), metadata.into());
}
}
for (k, v) in data.as_value() {
assert_eq!(object.insert(k, v), None, "No duplicate name expected");
}
object
}
pub fn from_value(raw: owned::Value, author: IdentityStub) -> Result<Self, decode::Error> {
{
struct BaseOp {
r#type: u64,
timestamp: u64,
nonce: Nonce,
metadata: Option<Vec<(String, String)>>,
}
let base_op: BaseOp = {
use crate::replica::entity::operation::operation_data::get;
let mut object = raw.clone().try_into_object()?;
let r#type = get! {object, "type", try_as_u64, decode::Error};
let timestamp = get! {object, "timestamp", try_as_u64, decode::Error};
let nonce =
Nonce::try_from(get! {object, "nonce", try_into_string, decode::Error})?;
let metadata = get! {@option[next] object, "metadata", |some: owned::Value| {
let object = some.try_into_object()?;
Ok::<_, decode::Error>(
Some(get! {@mk_map object, try_into_string, decode::Error}))
}, read::Error};
BaseOp {
r#type,
timestamp,
nonce,
metadata,
}
};
let operation_data =
E::OperationData::from_value(raw, base_op.r#type).map_err(|err| {
decode::Error::DateDecode(err.to_string())
})?;
let id = {
fn html_escape(input: &str) -> String {
let mut output = String::new();
for ch in input.chars() {
let next = match ch {
'<' => &['\\', 'u', '0', '0', '3', 'c'][..],
'>' => &['\\', 'u', '0', '0', '3', 'e'][..],
'&' => &['\\', 'u', '0', '0', '2', '6'][..],
'\u{2028}' => &['\\', 'u', '2', '0', '2', '8'][..],
'\u{2029}' => &['\\', 'u', '2', '0', '2', '9'][..],
_ => &[ch][..],
};
for ch in next {
output.push(*ch);
}
}
output
}
let mut hasher = Sha256::new();
let object = Value::Object(Box::new(Self::as_value_parts(
&operation_data,
base_op.timestamp,
base_op.nonce,
base_op.metadata.as_ref(),
)));
let str_escaped = { html_escape(&object.encode()) };
hasher.update(str_escaped);
let result = hasher.finalize();
let id = Id::from_sha256_hash(&result);
unsafe {
EntityId::from_id(id)
}
};
Ok(Self {
author,
creation_time: TimeStamp::from(base_op.timestamp),
metadata: base_op.metadata,
nonce: base_op.nonce,
data: operation_data,
id,
})
}
}
pub fn id(&self) -> EntityId<E> {
self.id
}
}
#[allow(missing_docs)]
pub mod decode {
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Failed to read this operation's specific data: {0}")]
DateDecode(String),
#[error("Expected the value to be object: {0}")]
ValueNotObject(#[from] simd_json::TryTypeError),
#[error("Object was missing the '{field}' field")]
MissingJsonField { field: &'static str },
#[error("Expected the '{field}' field to be a certain type, but was it not: {err}.")]
WrongJsonType {
err: simd_json::TryTypeError,
field: &'static str,
},
#[error("Failed to decode the Nonce as base64: {0}")]
NonceParse(#[from] base64::DecodeSliceError),
}
}
#[cfg(test)]
mod test {
use simd_json::prelude::Writable;
use super::Operation;
use crate::{
entities::issue::{Issue, issue_operation::IssueOperationData},
replica::entity::{
id::{Id, entity_id::EntityId},
identity::IdentityStub,
nonce::Nonce,
timestamp::TimeStamp,
},
};
fn roundtrip(start: &Operation<Issue>) -> Operation<Issue> {
let mut string: String =
simd_json::borrowed::Value::Object(Box::new(start.as_value())).encode();
eprintln!("Encoded: {string}");
let end = Operation::<Issue>::from_value(
simd_json::to_owned_value(unsafe { string.as_bytes_mut() }).unwrap(),
start.author,
)
.unwrap();
end
}
fn assert_equal(start: &Operation<Issue>, end: &Operation<Issue>) {
assert_eq!(start.author, end.author);
assert_eq!(unsafe { start.creation_time.to_unsafe() }, unsafe {
end.creation_time.to_unsafe()
});
assert_eq!(start.metadata, end.metadata);
assert_eq!(start.nonce, end.nonce);
assert_eq!(start.id, end.id);
assert_eq!(start.data, end.data);
}
#[test]
fn operation_round_trip_simple() {
let start = Operation::<Issue> {
author: IdentityStub {
id: unsafe {
EntityId::from_id(
Id::from_hex(
b"1df6ca7c48f3e061c9659887a651e02154307c18d56607a50828280255415e21",
)
.unwrap(),
)
},
},
creation_time: TimeStamp::from(1_745_068_324),
metadata: None,
nonce: Nonce::try_from("YdUYiTWowuc/QkH3hKK3ewjqi1s=").unwrap(),
id: unsafe {
EntityId::from_id(
Id::from_hex(
b"ff28595c4f5236549cab1cfc7fd7c42b7c37352a8a59a70e3b0b4a82b821c735",
)
.unwrap(),
)
},
data: IssueOperationData::Create {
title: "test 73".to_owned(),
message: "test1".to_owned(),
files: vec![],
},
};
let end = roundtrip(&start);
assert_equal(&start, &end);
}
#[test]
fn operation_round_trip_html_triggers() {
let start = Operation::<Issue> {
author: IdentityStub {
id: unsafe {
EntityId::from_id(
Id::from_hex(
b"1df6ca7c48f3e061c9659887a651e02154307c18d56607a50828280255415e21",
)
.unwrap(),
)
},
},
creation_time: TimeStamp::from(1_748_601_272),
metadata: None,
nonce: Nonce::try_from("YZjlOqrXSFy/OZiAJS3y5CrBxgg=").unwrap(),
id: unsafe {
EntityId::from_id(
Id::from_hex(
b"dc872211c65d3fb533d0d303b658261b5b0ed287ba728d305d0014e1b19ac027",
)
.unwrap(),
)
},
data: IssueOperationData::Create {
title: "<>".to_owned(),
message: String::new(),
files: vec![],
},
};
let end = roundtrip(&start);
assert_equal(&start, &end);
}
#[test]
fn operation_round_trip_long() {
let start = Operation::<Issue> {
author: IdentityStub {
id: unsafe {
EntityId::from_id(
Id::from_hex(
b"7f24a6ff7ee2ed2c60904026359f0f4818e6466ccb0582fedd8eaa04edabbdd5",
)
.unwrap(),
)
},
},
creation_time: TimeStamp::from(1_537_546_348),
metadata: Some(vec![
(
"github-id".to_owned(),
"MDU6SXNzdWUzNjI2ODM2Mzk=".to_owned(),
),
(
"github-url".to_owned(),
"https://github.com/rust-lang/rust/issues/54437".to_owned(),
),
("origin".to_owned(), "github".to_owned()),
]),
nonce: Nonce::try_from("Q2M2mXgBZaBQKKUnS5QBxky00P8=").unwrap(),
id: unsafe {
EntityId::from_id(
Id::from_hex(
b"b8f4a62333974e95eb69e6956d07e799662acefd28b00ab83fcd72d1ef6522eb",
)
.unwrap(),
)
},
data: IssueOperationData::Create {
title: "In beta 1.30.0-beta.2, `cargo test` runs rustdoc with \
`-Zunstable-options`, which errors"
.to_owned(),
message: "In beta 1.30.0-beta.2, `cargo test` runs `rustdoc -Zunstable-options \
--edition=2018 ...` which errors out with \n\n> error: the option `Z` \
is only accepted on the nightly compiler\n\nUsing `rustdoc \
--edition=2018 ...`, omitting the `-Zunstable-options` flag, works as \
expected.\n\n## Meta\n```\ncargo --version --verbose\ncargo 1.30.0-beta \
(308b1eabd 2018-09-19)\nrelease: 1.30.0\ncommit-hash: \
308b1eabd6195812b91d646a0292224bb014b449\ncommit-date: \
2018-09-19\n\nrustdoc --version --verbose\nrustdoc 1.30.0-beta.2 \
(7a0062e46 2018-09-19)\nbinary: rustdoc\ncommit-hash: \
7a0062e46844def0edcd86da1abafafd9cdbbeaf\ncommit-date: \
2018-09-19\nhost: x86_64-apple-darwin\nrelease: 1.30.0-beta.2\nLLVM \
version: 8.0\n```"
.to_owned(),
files: vec![],
},
};
let end = roundtrip(&start);
assert_equal(&start, &end);
}
}