Skip to content

Commit 9224679

Browse files
author
Bennett Hardwick
authored
Merge pull request #61 from cipherstash/fix/seal-all-optional-attributes
Support loading Option<T> from missing attributes
2 parents 6d19d87 + 0d53904 commit 9224679

File tree

9 files changed

+214
-321
lines changed

9 files changed

+214
-321
lines changed

Cargo.lock

Lines changed: 37 additions & 293 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ homepage = "https://cipherstash.com"
55
repository = "https://github.com/cipherstash/cipherstash-dynamodb"
66
readme = "README.md"
77
description = "CipherStash client for storing and querying encrypted data in DynamoDB"
8-
version = "0.5.0"
8+
version = "0.6.0"
99
edition = "2021"
1010

1111
[package.metadata.docs.rs]
@@ -24,8 +24,8 @@ repository = "https://github.com/cipherstash/cipherstash-dynamodb"
2424
# and it will keep the alphabetic ordering for you.
2525

2626
[dependencies]
27-
cipherstash-client = { version = "0.9", registry = "cipherstash" }
28-
cipherstash-dynamodb-derive = { version = "0.5", path = "cipherstash-dynamodb-derive", registry = "cipherstash" }
27+
cipherstash-client = { version = "0.10", registry = "cipherstash" }
28+
cipherstash-dynamodb-derive = { version = "0.6", path = "cipherstash-dynamodb-derive", registry = "cipherstash" }
2929

3030
aws-sdk-dynamodb = "1.3.0"
3131
async-trait = "0.1.73"

cipherstash-dynamodb-derive/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ license-file = "../LICENSE.md"
44
homepage = "https://cipherstash.com"
55
readme = "../README.md"
66
description = "Derive macros for the CipherStash client for DynamoDB"
7-
version = "0.5.0"
7+
version = "0.6.0"
88
edition = "2021"
99

1010
[lib]

cipherstash-dynamodb-derive/src/decryptable.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ pub(crate) fn derive_decryptable(input: DeriveInput) -> Result<TokenStream, syn:
3131
let attr_ident = format_ident!("{attr}");
3232

3333
quote! {
34-
#attr_ident: ::cipherstash_dynamodb::traits::TryFromPlaintext::try_from_plaintext(unsealed.get_protected(#attr)?.to_owned())?
34+
#attr_ident: ::cipherstash_dynamodb::traits::TryFromPlaintext::try_from_optional_plaintext(unsealed.get_protected(#attr).cloned())?
3535
}
3636
})
3737
.chain(plaintext_attributes.iter().map(|attr| {
3838
let attr_ident = format_ident!("{attr}");
3939

4040
quote! {
41-
#attr_ident: ::cipherstash_dynamodb::traits::TryFromTableAttr::try_from_table_attr(unsealed.get_plaintext(#attr)?)?
41+
#attr_ident: ::cipherstash_dynamodb::traits::TryFromTableAttr::try_from_table_attr(unsealed.get_plaintext(#attr))?
4242
}
4343
}))
4444
.chain(skipped_attributes.iter().map(|attr| {

src/async_map_somes.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use std::future::Future;
2+
3+
/// Take an input vector and run a callback over only the Somes in the vec.
4+
///
5+
/// The callback must return a vector in the same order and equal in length to the Somes.
6+
pub async fn async_map_somes<T, U, E, F: Future<Output = Result<Vec<U>, E>>>(
7+
input: Vec<Option<T>>,
8+
callback: impl FnOnce(Vec<T>) -> F,
9+
) -> Result<Vec<Option<U>>, E> {
10+
let mut output = Vec::with_capacity(input.len());
11+
output.resize_with(input.len(), || None);
12+
13+
let (indexes, somes): (Vec<usize>, Vec<T>) = input
14+
.into_iter()
15+
.enumerate()
16+
.filter_map(|(i, x)| x.map(|y| (i, y)))
17+
.unzip();
18+
19+
let somes_len = somes.len();
20+
21+
let callback_result = callback(somes).await?;
22+
23+
assert_eq!(
24+
callback_result.len(),
25+
somes_len,
26+
"expected input length to equal output length"
27+
);
28+
29+
for (x, i) in callback_result.into_iter().zip(indexes.into_iter()) {
30+
output[i] = Some(x);
31+
}
32+
33+
Ok(output)
34+
}
35+
36+
#[cfg(test)]
37+
mod tests {
38+
use super::*;
39+
40+
#[tokio::test]
41+
#[should_panic]
42+
async fn test_array_different_size() {
43+
async_map_somes(vec![Some(10)], |_| async { Ok::<Vec<()>, ()>(vec![]) })
44+
.await
45+
.unwrap();
46+
}
47+
48+
#[tokio::test]
49+
async fn test_maintain_order() {
50+
let input = vec![None, Some(1_u8), None, Some(2_u8), None, Some(3_u8)];
51+
52+
let output = async_map_somes(input.clone(), |x| async { Ok::<_, ()>(x) })
53+
.await
54+
.unwrap();
55+
56+
assert_eq!(input, output);
57+
}
58+
}

src/crypto/sealed.rs

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::{
2+
async_map_somes::async_map_somes,
23
encrypted_table::{TableAttribute, TableEntry},
34
traits::{ReadConversionError, WriteConversionError},
45
Decryptable,
@@ -62,7 +63,8 @@ impl SealedTableEntry {
6263
plaintext_attributes,
6364
} = spec;
6465

65-
let mut plaintext_items: Vec<Vec<&TableAttribute>> = Vec::with_capacity(items.len());
66+
let mut plaintext_items: Vec<Vec<Option<&TableAttribute>>> =
67+
Vec::with_capacity(items.len());
6668
let mut decryptable_items = Vec::with_capacity(items.len() * protected_attributes.len());
6769

6870
for item in items.iter() {
@@ -77,9 +79,11 @@ impl SealedTableEntry {
7779
});
7880

7981
attribute
80-
.ok_or_else(|| SealError::MissingAttribute(name.to_string()))?
81-
.as_encrypted_record()
82-
.ok_or_else(|| SealError::InvalidCiphertext(name.to_string()))
82+
.map(|x| {
83+
x.as_encrypted_record()
84+
.ok_or_else(|| SealError::InvalidCiphertext(name.to_string()))
85+
})
86+
.transpose()
8387
})
8488
.collect::<Result<Vec<_>, _>>()?;
8589

@@ -97,23 +101,21 @@ impl SealedTableEntry {
97101
_ => name,
98102
};
99103

100-
item.inner()
101-
.attributes
102-
.get(attr)
103-
.ok_or(SealError::MissingAttribute(attr.to_string()))
104+
item.inner().attributes.get(attr)
104105
})
105-
.collect::<Result<Vec<&TableAttribute>, SealError>>()?;
106+
.collect::<Vec<Option<&TableAttribute>>>();
106107

107108
plaintext_items.push(unprotected);
108109
}
109110

110-
let decrypted = cipher.decrypt(decryptable_items).await?;
111+
let decrypted = async_map_somes(decryptable_items, |items| cipher.decrypt(items)).await?;
111112

112-
let decrypted_iter: &mut dyn Iterator<Item = &[Plaintext]> =
113+
let decrypted_iter: &mut dyn Iterator<Item = &[Option<Plaintext>]> =
113114
if protected_attributes.len() > 0 {
114115
&mut decrypted.chunks_exact(protected_attributes.len())
115116
} else {
116-
&mut std::iter::repeat_with::<&[Plaintext], _>(|| &[]).take(plaintext_items.len())
117+
&mut std::iter::repeat_with::<&[Option<Plaintext>], _>(|| &[])
118+
.take(plaintext_items.len())
117119
};
118120

119121
let unsealed = decrypted_iter
@@ -122,13 +124,17 @@ impl SealedTableEntry {
122124
let mut unsealed = Unsealed::new();
123125

124126
for (name, plaintext) in protected_attributes.iter().zip(decrypted_plaintext) {
125-
unsealed.add_protected(name.to_string(), plaintext.clone());
127+
if let Some(plaintext) = plaintext {
128+
unsealed.add_protected(name.to_string(), plaintext.clone());
129+
}
126130
}
127131

128132
for (name, plaintext) in
129133
plaintext_attributes.iter().zip(plaintext_items.into_iter())
130134
{
131-
unsealed.add_unprotected(name.to_string(), plaintext.clone());
135+
if let Some(plaintext) = plaintext {
136+
unsealed.add_unprotected(name.to_string(), plaintext.clone());
137+
}
132138
}
133139

134140
unsealed

src/crypto/unsealed.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,17 @@ impl Unsealed {
4545
&self.unprotected
4646
}
4747

48-
pub fn get_protected(&self, name: &str) -> Result<&Plaintext, SealError> {
49-
let (plaintext, _) = self
50-
.protected
51-
.get(name)
52-
.ok_or_else(|| SealError::MissingAttribute(name.to_string()))?;
48+
pub fn get_protected(&self, name: &str) -> Option<&Plaintext> {
49+
let (plaintext, _) = self.protected.get(name)?;
5350

54-
Ok(plaintext)
51+
Some(plaintext)
5552
}
5653

57-
pub fn get_plaintext(&self, name: &str) -> Result<TableAttribute, SealError> {
54+
pub fn get_plaintext(&self, name: &str) -> TableAttribute {
5855
self.unprotected
5956
.get(name)
6057
.cloned()
61-
.ok_or_else(|| SealError::MissingAttribute(name.to_string()))
58+
.unwrap_or(TableAttribute::Null)
6259
}
6360

6461
pub fn add_protected(&mut self, name: impl Into<String>, plaintext: Plaintext) {

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,4 +593,6 @@ pub use cipherstash_dynamodb_derive::{Decryptable, Encryptable, Identifiable, Se
593593
// Re-exports
594594
pub use cipherstash_client::encryption;
595595

596+
mod async_map_somes;
597+
596598
pub type Key = [u8; 32];

tests/decrypt_optional_fields.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use cipherstash_dynamodb::{
2+
Decryptable, Encryptable, EncryptedTable, Identifiable, Pk, Searchable,
3+
};
4+
use serial_test::serial;
5+
use std::{borrow::Cow, future::Future};
6+
7+
mod common;
8+
9+
#[derive(Encryptable, Decryptable, Searchable, Debug, PartialEq, Ord, PartialOrd, Eq)]
10+
pub struct User {
11+
#[cipherstash(query = "exact")]
12+
encrypted: Option<String>,
13+
#[cipherstash(plaintext)]
14+
plaintext: Option<String>,
15+
}
16+
17+
impl Identifiable for User {
18+
type PrimaryKey = Pk;
19+
20+
fn get_primary_key(&self) -> Self::PrimaryKey {
21+
Pk("user".into())
22+
}
23+
24+
fn type_name() -> Cow<'static, str> {
25+
"user".into()
26+
}
27+
28+
fn sort_key_prefix() -> Option<Cow<'static, str>> {
29+
None
30+
}
31+
}
32+
33+
#[derive(Encryptable, Decryptable, Searchable, Debug, PartialEq, Ord, PartialOrd, Eq)]
34+
pub struct Empty {}
35+
36+
impl Identifiable for Empty {
37+
type PrimaryKey = Pk;
38+
39+
fn get_primary_key(&self) -> Self::PrimaryKey {
40+
Pk("user".into())
41+
}
42+
43+
fn type_name() -> Cow<'static, str> {
44+
"user".into()
45+
}
46+
47+
fn sort_key_prefix() -> Option<Cow<'static, str>> {
48+
None
49+
}
50+
}
51+
52+
async fn run_test<F: Future<Output = ()>>(mut f: impl FnMut(EncryptedTable) -> F) {
53+
let config = aws_config::from_env()
54+
.endpoint_url("http://localhost:8000")
55+
.load()
56+
.await;
57+
58+
let client = aws_sdk_dynamodb::Client::new(&config);
59+
60+
let table_name = "empty-record-load";
61+
62+
common::create_table(&client, table_name).await;
63+
64+
let table = EncryptedTable::init(client, table_name)
65+
.await
66+
.expect("Failed to init table");
67+
68+
table
69+
.put(Empty {})
70+
.await
71+
.expect("Failed to insert empty record");
72+
73+
f(table).await;
74+
}
75+
76+
#[tokio::test]
77+
#[serial]
78+
async fn test_load_from_empty() {
79+
run_test(|table| async move {
80+
table
81+
.get::<User>(Pk("user".into()))
82+
.await
83+
.expect("failed to get user");
84+
})
85+
.await
86+
}

0 commit comments

Comments
 (0)