pub use kcode_k1_access_profile_codec::{
AuthorizationProfile, GroupId, ModelId, ProfileOwner, ProfileViewer, TxId, UserId,
decode_profile, encode_profile,
};
pub use kcode_k1_access_profile_records::ProfileName;
pub use kcode_k1_access_profile_values::ProfileId;
const WIRE_VERSION: u8 = 1;
const CREATE_TAG: u8 = 1;
const REPLACE_TAG: u8 = 2;
const DELETE_TAG: u8 = 3;
const CREATE_NAMED_TAG: u8 = 4;
const RENAME_TAG: u8 = 5;
const HEADER_BYTES: usize = 2;
const OPERATION_ID_BYTES: usize = 16;
const TX_ID_BYTES: usize = 12;
const NAME_LENGTH_BYTES: usize = 4;
const CREATE_PREFIX_BYTES: usize = HEADER_BYTES + OPERATION_ID_BYTES + TX_ID_BYTES;
const REPLACE_PREFIX_BYTES: usize = CREATE_PREFIX_BYTES + TX_ID_BYTES;
pub type OperationId = [u8; OPERATION_ID_BYTES];
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ProfileMutation {
Create {
owner: UserId,
profile: AuthorizationProfile,
},
Replace {
profile_id: ProfileId,
actor: UserId,
profile: AuthorizationProfile,
},
Delete {
profile_id: ProfileId,
actor: UserId,
},
CreateNamed {
owner: UserId,
name: ProfileName,
profile: AuthorizationProfile,
},
Rename {
profile_id: ProfileId,
actor: UserId,
name: ProfileName,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProfileOperation {
operation_id: OperationId,
mutation: ProfileMutation,
}
impl ProfileOperation {
pub const fn new(operation_id: OperationId, mutation: ProfileMutation) -> Self {
Self {
operation_id,
mutation,
}
}
pub const fn operation_id(&self) -> OperationId {
self.operation_id
}
pub const fn mutation(&self) -> &ProfileMutation {
&self.mutation
}
pub fn into_mutation(self) -> ProfileMutation {
self.mutation
}
pub fn into_parts(self) -> (OperationId, ProfileMutation) {
(self.operation_id, self.mutation)
}
}
pub fn encode_operation(operation: &ProfileOperation) -> Result<Vec<u8>, String> {
let operation_id = operation.operation_id();
match operation.mutation() {
ProfileMutation::Create { owner, profile } => {
let profile_bytes = encode_profile(profile)?;
let capacity = CREATE_PREFIX_BYTES
.checked_add(profile_bytes.len())
.ok_or_else(|| "profile payload length overflow".to_owned())?;
let mut payload = Vec::with_capacity(capacity);
push_header(&mut payload, CREATE_TAG, &operation_id);
payload.extend_from_slice(owner.as_tx_id().as_bytes());
payload.extend_from_slice(&profile_bytes);
Ok(payload)
}
ProfileMutation::Replace {
profile_id,
actor,
profile,
} => {
let profile_bytes = encode_profile(profile)?;
let capacity = REPLACE_PREFIX_BYTES
.checked_add(profile_bytes.len())
.ok_or_else(|| "profile payload length overflow".to_owned())?;
let mut payload = Vec::with_capacity(capacity);
push_header(&mut payload, REPLACE_TAG, &operation_id);
push_subjects(&mut payload, profile_id, actor);
payload.extend_from_slice(&profile_bytes);
Ok(payload)
}
ProfileMutation::Delete { profile_id, actor } => {
let mut payload = Vec::with_capacity(REPLACE_PREFIX_BYTES);
push_header(&mut payload, DELETE_TAG, &operation_id);
push_subjects(&mut payload, profile_id, actor);
Ok(payload)
}
ProfileMutation::CreateNamed {
owner,
name,
profile,
} => {
let profile_bytes = encode_profile(profile)?;
let (name_length, name_bytes) = encoded_name(name)?;
let capacity = CREATE_PREFIX_BYTES
.checked_add(NAME_LENGTH_BYTES)
.and_then(|length| length.checked_add(name_bytes.len()))
.and_then(|length| length.checked_add(profile_bytes.len()))
.ok_or_else(|| "profile payload length overflow".to_owned())?;
let mut payload = Vec::with_capacity(capacity);
push_header(&mut payload, CREATE_NAMED_TAG, &operation_id);
payload.extend_from_slice(owner.as_tx_id().as_bytes());
push_name(&mut payload, name_length, name_bytes);
payload.extend_from_slice(&profile_bytes);
Ok(payload)
}
ProfileMutation::Rename {
profile_id,
actor,
name,
} => {
let (name_length, name_bytes) = encoded_name(name)?;
let capacity = REPLACE_PREFIX_BYTES
.checked_add(NAME_LENGTH_BYTES)
.and_then(|length| length.checked_add(name_bytes.len()))
.ok_or_else(|| "profile payload length overflow".to_owned())?;
let mut payload = Vec::with_capacity(capacity);
push_header(&mut payload, RENAME_TAG, &operation_id);
push_subjects(&mut payload, profile_id, actor);
push_name(&mut payload, name_length, name_bytes);
Ok(payload)
}
}
}
fn push_header(payload: &mut Vec<u8>, tag: u8, operation_id: &OperationId) {
payload.extend_from_slice(&[WIRE_VERSION, tag]);
payload.extend_from_slice(operation_id);
}
fn push_subjects(payload: &mut Vec<u8>, profile_id: &ProfileId, actor: &UserId) {
payload.extend_from_slice(profile_id.txid().as_bytes());
payload.extend_from_slice(actor.as_tx_id().as_bytes());
}
fn encoded_name(name: &ProfileName) -> Result<(u32, &[u8]), String> {
let bytes = name.as_str().as_bytes();
let length = u32::try_from(bytes.len())
.map_err(|_| "profile name exceeds canonical encoding".to_owned())?;
Ok((length, bytes))
}
fn push_name(payload: &mut Vec<u8>, length: u32, bytes: &[u8]) {
payload.extend_from_slice(&length.to_be_bytes());
payload.extend_from_slice(bytes);
}
pub fn parse_operation(payload: &[u8]) -> Result<ProfileOperation, String> {
if payload.len() < HEADER_BYTES {
return Err("profile payload header is truncated".to_owned());
}
if payload[0] != WIRE_VERSION {
return Err("unsupported profile payload version".to_owned());
}
match payload[1] {
CREATE_TAG => parse_create(payload),
REPLACE_TAG => parse_replace(payload),
DELETE_TAG => parse_delete(payload),
CREATE_NAMED_TAG => parse_create_named(payload),
RENAME_TAG => parse_rename(payload),
_ => Err("unsupported profile payload action".to_owned()),
}
}
fn parse_create(payload: &[u8]) -> Result<ProfileOperation, String> {
if payload.len() <= CREATE_PREFIX_BYTES {
return Err("create profile payload is truncated".to_owned());
}
Ok(ProfileOperation::new(
read_operation_id(payload),
ProfileMutation::Create {
owner: UserId::from_tx_id(read_txid(&payload[18..30])),
profile: decode_profile(&payload[CREATE_PREFIX_BYTES..])?,
},
))
}
fn parse_replace(payload: &[u8]) -> Result<ProfileOperation, String> {
if payload.len() <= REPLACE_PREFIX_BYTES {
return Err("replace profile payload is truncated".to_owned());
}
Ok(ProfileOperation::new(
read_operation_id(payload),
ProfileMutation::Replace {
profile_id: ProfileId::new(read_txid(&payload[18..30])),
actor: UserId::from_tx_id(read_txid(&payload[30..42])),
profile: decode_profile(&payload[REPLACE_PREFIX_BYTES..])?,
},
))
}
fn parse_delete(payload: &[u8]) -> Result<ProfileOperation, String> {
if payload.len() != REPLACE_PREFIX_BYTES {
return Err("delete profile payload must be exactly 42 bytes".to_owned());
}
Ok(ProfileOperation::new(
read_operation_id(payload),
ProfileMutation::Delete {
profile_id: ProfileId::new(read_txid(&payload[18..30])),
actor: UserId::from_tx_id(read_txid(&payload[30..42])),
},
))
}
fn parse_create_named(payload: &[u8]) -> Result<ProfileOperation, String> {
if payload.len() <= CREATE_PREFIX_BYTES {
return Err("create named profile payload is truncated".to_owned());
}
let (name, profile_offset) = parse_name(payload, CREATE_PREFIX_BYTES)?;
if payload.len() <= profile_offset {
return Err("create named profile payload is truncated".to_owned());
}
Ok(ProfileOperation::new(
read_operation_id(payload),
ProfileMutation::CreateNamed {
owner: UserId::from_tx_id(read_txid(&payload[18..30])),
name,
profile: decode_profile(&payload[profile_offset..])?,
},
))
}
fn parse_rename(payload: &[u8]) -> Result<ProfileOperation, String> {
if payload.len() <= REPLACE_PREFIX_BYTES {
return Err("rename profile payload is truncated".to_owned());
}
let (name, end) = parse_name(payload, REPLACE_PREFIX_BYTES)?;
if end != payload.len() {
return Err("trailing bytes in rename profile payload".to_owned());
}
Ok(ProfileOperation::new(
read_operation_id(payload),
ProfileMutation::Rename {
profile_id: ProfileId::new(read_txid(&payload[18..30])),
actor: UserId::from_tx_id(read_txid(&payload[30..42])),
name,
},
))
}
fn parse_name(payload: &[u8], offset: usize) -> Result<(ProfileName, usize), String> {
let length_end = offset
.checked_add(NAME_LENGTH_BYTES)
.ok_or_else(|| "profile name encoding is truncated".to_owned())?;
let length_slice = payload
.get(offset..length_end)
.ok_or_else(|| "profile name encoding is truncated".to_owned())?;
let mut length_bytes = [0; NAME_LENGTH_BYTES];
length_bytes.copy_from_slice(length_slice);
let length = usize::try_from(u32::from_be_bytes(length_bytes))
.map_err(|_| "profile name length does not fit this platform".to_owned())?;
let end = length_end
.checked_add(length)
.ok_or_else(|| "profile name encoding is truncated".to_owned())?;
let bytes = payload
.get(length_end..end)
.ok_or_else(|| "profile name encoding is truncated".to_owned())?;
let text =
std::str::from_utf8(bytes).map_err(|_| "profile name is not valid UTF-8".to_owned())?;
let name =
ProfileName::new(text.to_owned()).map_err(|_| "profile name is invalid".to_owned())?;
Ok((name, end))
}
fn read_operation_id(payload: &[u8]) -> OperationId {
let mut operation_id = [0; OPERATION_ID_BYTES];
operation_id.copy_from_slice(&payload[2..18]);
operation_id
}
fn read_txid(bytes: &[u8]) -> TxId {
let mut txid = [0; TX_ID_BYTES];
txid.copy_from_slice(bytes);
TxId::from_bytes(txid)
}
#[cfg(test)]
mod tests {
use super::*;
use super::{ProfileMutation as M, ProfileOwner as O, ProfileViewer as V};
fn operation(tag: u8, body: &[&[u8]]) -> Vec<u8> {
[&[WIRE_VERSION, tag][..], &[7; 16], &body.concat()].concat()
}
#[test]
fn exact_mutation_vectors_round_trip() {
let tx = |byte| TxId::from_bytes([byte; 12]);
let profile = AuthorizationProfile::new(
vec![
O::Group(GroupId::new(tx(3))),
O::RequestUser,
O::User(UserId::from_tx_id(tx(2))),
],
vec![
V::Model(ModelId::from_bytes([5; 32])),
V::Group(GroupId::new(tx(4))),
V::RequestUser,
V::User(UserId::from_tx_id(tx(3))),
V::RequestModel,
],
)
.expect("profile");
let profile_bytes = [
&[1, 0, 0, 0, 3, 0, 1][..],
&[2; 12],
&[2],
&[3; 12],
&[0, 0, 0, 5, 0, 1, 2],
&[3; 12],
&[3],
&[4; 12],
&[4],
&[5; 32],
]
.concat();
let user = UserId::from_tx_id(tx(8));
let profile_id = ProfileId::new(tx(9));
let name = ProfileName::new("Café".to_owned()).expect("name");
let cases = [
(
M::Create {
owner: user,
profile: profile.clone(),
},
operation(1, &[&[8; 12], &profile_bytes]),
127,
),
(
M::Replace {
profile_id,
actor: user,
profile: profile.clone(),
},
operation(2, &[&[9; 12], &[8; 12], &profile_bytes]),
139,
),
(
M::Delete {
profile_id,
actor: user,
},
operation(3, &[&[9; 12], &[8; 12]]),
42,
),
(
M::CreateNamed {
owner: user,
name: name.clone(),
profile: profile.clone(),
},
operation(
4,
&[
&[8; 12],
&5_u32.to_be_bytes(),
b"Caf\xc3\xa9",
&profile_bytes,
],
),
136,
),
(
M::Rename {
profile_id,
actor: user,
name,
},
operation(
5,
&[&[9; 12], &[8; 12], &5_u32.to_be_bytes(), b"Caf\xc3\xa9"],
),
51,
),
];
for (mutation, expected, exact_len) in cases {
let operation = ProfileOperation::new([7; 16], mutation);
let encoded = encode_operation(&operation).expect("encoding");
assert_eq!((encoded.len(), &encoded), (exact_len, &expected));
assert_eq!(parse_operation(&encoded).expect("parsing"), operation);
}
}
#[test]
fn rejects_mutation_inputs_with_exact_errors() {
let named =
|declared: u32, text: &[u8]| operation(4, &[&[1; 12], &declared.to_be_bytes(), text]);
for (input, expected) in [
(vec![], "profile payload header is truncated"),
(vec![1], "profile payload header is truncated"),
(vec![2, 9], "unsupported profile payload version"),
(vec![1, 9], "unsupported profile payload action"),
(vec![1, 1], "create profile payload is truncated"),
(vec![1, 2], "replace profile payload is truncated"),
(
vec![1, 3],
"delete profile payload must be exactly 42 bytes",
),
(vec![1, 4], "create named profile payload is truncated"),
(vec![1, 5], "rename profile payload is truncated"),
(
operation(1, &[&[1; 12], &[2]]),
"unknown profile encoding version",
),
(
operation(3, &[&[0; 25]]),
"delete profile payload must be exactly 42 bytes",
),
(
operation(4, &[&[1; 12], &[0, 0, 0]]),
"profile name encoding is truncated",
),
(named(2, b"a"), "profile name encoding is truncated"),
(named(1, &[0xff]), "profile name is not valid UTF-8"),
(named(0, b""), "profile name is invalid"),
(named(8, b"bad\nname"), "profile name is invalid"),
(named(6, b" "), "profile name is invalid"),
(
named(5, b"Caf\xc3\xa9"),
"create named profile payload is truncated",
),
(
[
operation(
5,
&[&[9; 12], &[8; 12], &5_u32.to_be_bytes(), b"Caf\xc3\xa9"],
),
vec![0],
]
.concat(),
"trailing bytes in rename profile payload",
),
] {
assert_eq!(parse_operation(&input).unwrap_err(), expected);
}
}
}