use std::cell::RefCell;
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Mutex;
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key as AesKey, Nonce};
use base64::engine::general_purpose::STANDARD_NO_PAD;
use base64::Engine as _;
use hkdf::Hkdf;
use rand::Rng as _;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use sha2::Sha256;
use crate::llm::vm_value_to_json;
use crate::stdlib::host::dispatch_host_operation;
use crate::value::{VmError, VmValue};
use crate::vm::Vm;
const HANDLE_KEY_KIND: &str = "kind";
const HANDLE_KEY_ID: &str = "id";
const HANDLE_KEY_PATH: &str = "path";
const HANDLE_KEY_SCOPE: &str = "scope";
const KIND_MEMORY: &str = "memory";
const KIND_FILE: &str = "file";
const KIND_HARN_CLOUD_SESSION: &str = "harn_cloud_session";
const KIND_HARN_CLOUD_ORG: &str = "harn_cloud_org";
const HKDF_INFO: &[u8] = b"harn-oauth-storage-v1";
const FILE_ENVELOPE_VERSION: u32 = 1;
thread_local! {
static MEMORY_STORE: RefCell<BTreeMap<String, BTreeMap<String, StoredEntry>>> =
const { RefCell::new(BTreeMap::new()) };
static FILE_SECRETS: RefCell<BTreeMap<String, Vec<u8>>> =
const { RefCell::new(BTreeMap::new()) };
}
static MEMORY_ID_COUNTER: Mutex<u64> = Mutex::new(0);
static FILE_ID_COUNTER: Mutex<u64> = Mutex::new(0);
#[derive(Clone, Debug, Serialize, Deserialize)]
struct StoredEntry {
token: JsonValue,
#[serde(default, skip_serializing_if = "Option::is_none")]
ttl_seconds: Option<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct FileEnvelope {
version: u32,
nonce: String,
ciphertext: String,
}
pub(crate) fn register_oauth_storage_builtins(vm: &mut Vm) {
vm.register_builtin("__oauth_storage_memory_handle", |args, _out| {
if !args.is_empty() {
return Err(VmError::Runtime(
"__oauth_storage_memory_handle: expected 0 arguments".to_string(),
));
}
Ok(memory_handle())
});
vm.register_builtin("__oauth_storage_file_handle", |args, _out| {
let path = required_string_arg(args, 0, "__oauth_storage_file_handle", "path")?;
let secret = required_bytes_or_string(args, 1, "__oauth_storage_file_handle")?;
Ok(file_handle(&path, &secret))
});
vm.register_builtin("__oauth_storage_cloud_handle", |args, _out| {
let scope = required_string_arg(args, 0, "__oauth_storage_cloud_handle", "scope")?;
let kind = match scope.as_str() {
"session" => KIND_HARN_CLOUD_SESSION,
"org" => KIND_HARN_CLOUD_ORG,
other => {
return Err(VmError::Runtime(format!(
"__oauth_storage_cloud_handle: scope must be \"session\" or \"org\", got `{other}`"
)))
}
};
Ok(cloud_handle(kind))
});
vm.register_async_builtin("__oauth_storage_get", |args| async move {
let handle = require_handle(&args, 0, "__oauth_storage_get")?;
let key = required_string_arg(&args, 1, "__oauth_storage_get", "key")?;
backend_get(&handle, &key).await
});
vm.register_async_builtin("__oauth_storage_set", |args| async move {
let handle = require_handle(&args, 0, "__oauth_storage_set")?;
let key = required_string_arg(&args, 1, "__oauth_storage_set", "key")?;
let token_dict = require_dict_arg(&args, 2, "__oauth_storage_set", "token_set")?;
let ttl = optional_int_arg(&args, 3, "__oauth_storage_set", "ttl_seconds")?;
backend_set(&handle, &key, &token_dict, ttl).await?;
Ok(VmValue::Nil)
});
vm.register_async_builtin("__oauth_storage_delete", |args| async move {
let handle = require_handle(&args, 0, "__oauth_storage_delete")?;
let key = required_string_arg(&args, 1, "__oauth_storage_delete", "key")?;
backend_delete(&handle, &key).await?;
Ok(VmValue::Nil)
});
}
fn memory_handle() -> VmValue {
let id = {
let mut guard = MEMORY_ID_COUNTER
.lock()
.expect("memory id counter poisoned");
*guard = guard.wrapping_add(1);
format!("memory-{}", *guard)
};
let mut fields = BTreeMap::new();
fields.insert(HANDLE_KEY_KIND.to_string(), string_value(KIND_MEMORY));
fields.insert(HANDLE_KEY_ID.to_string(), string_value(&id));
VmValue::Dict(Rc::new(fields))
}
fn file_handle(path: &str, secret: &[u8]) -> VmValue {
let counter = {
let mut guard = FILE_ID_COUNTER.lock().expect("file id counter poisoned");
*guard = guard.wrapping_add(1);
*guard
};
let id = format!("file-{counter}");
FILE_SECRETS.with(|secrets| {
secrets.borrow_mut().insert(id.clone(), secret.to_vec());
});
let mut fields = BTreeMap::new();
fields.insert(HANDLE_KEY_KIND.to_string(), string_value(KIND_FILE));
fields.insert(HANDLE_KEY_ID.to_string(), string_value(&id));
fields.insert(HANDLE_KEY_PATH.to_string(), string_value(path));
VmValue::Dict(Rc::new(fields))
}
fn cloud_handle(kind: &'static str) -> VmValue {
let scope = if kind == KIND_HARN_CLOUD_ORG {
"org"
} else {
"session"
};
let mut fields = BTreeMap::new();
fields.insert(HANDLE_KEY_KIND.to_string(), string_value(kind));
fields.insert(HANDLE_KEY_ID.to_string(), string_value(kind));
fields.insert(HANDLE_KEY_SCOPE.to_string(), string_value(scope));
VmValue::Dict(Rc::new(fields))
}
fn string_value(value: &str) -> VmValue {
VmValue::String(Rc::from(value))
}
fn handle_kind(handle: &BTreeMap<String, VmValue>) -> Result<String, VmError> {
handle
.get(HANDLE_KEY_KIND)
.and_then(|value| match value {
VmValue::String(s) => Some(s.to_string()),
_ => None,
})
.ok_or_else(|| {
VmError::Runtime(
"oauth storage handle is missing `kind` (was the handle built by `oauth/storage`?)"
.to_string(),
)
})
}
fn handle_id(handle: &BTreeMap<String, VmValue>) -> Result<String, VmError> {
handle
.get(HANDLE_KEY_ID)
.and_then(|value| match value {
VmValue::String(s) => Some(s.to_string()),
_ => None,
})
.ok_or_else(|| VmError::Runtime("oauth storage handle is missing `id`".to_string()))
}
async fn backend_get(handle: &BTreeMap<String, VmValue>, key: &str) -> Result<VmValue, VmError> {
match handle_kind(handle)?.as_str() {
KIND_MEMORY => Ok(memory_get(&handle_id(handle)?, key)),
KIND_FILE => file_get(handle, key),
KIND_HARN_CLOUD_SESSION | KIND_HARN_CLOUD_ORG => cloud_get(handle, key).await,
other => unsupported_kind(other),
}
}
async fn backend_set(
handle: &BTreeMap<String, VmValue>,
key: &str,
token: &BTreeMap<String, VmValue>,
ttl_seconds: Option<i64>,
) -> Result<(), VmError> {
let json_token = vm_value_to_json(&VmValue::Dict(Rc::new(token.clone())));
match handle_kind(handle)?.as_str() {
KIND_MEMORY => {
memory_set(&handle_id(handle)?, key, json_token, ttl_seconds);
Ok(())
}
KIND_FILE => file_set(handle, key, json_token, ttl_seconds),
KIND_HARN_CLOUD_SESSION | KIND_HARN_CLOUD_ORG => {
cloud_set(handle, key, json_token, ttl_seconds).await
}
other => {
unsupported_kind::<()>(other)?;
Ok(())
}
}
}
async fn backend_delete(handle: &BTreeMap<String, VmValue>, key: &str) -> Result<(), VmError> {
match handle_kind(handle)?.as_str() {
KIND_MEMORY => {
memory_delete(&handle_id(handle)?, key);
Ok(())
}
KIND_FILE => file_delete(handle, key),
KIND_HARN_CLOUD_SESSION | KIND_HARN_CLOUD_ORG => cloud_delete(handle, key).await,
other => {
unsupported_kind::<()>(other)?;
Ok(())
}
}
}
fn unsupported_kind<T>(kind: &str) -> Result<T, VmError> {
Err(VmError::Runtime(format!(
"oauth storage: unsupported backend kind `{kind}`. Use `OAuth.Storage.custom` for user-supplied storage; built-in kinds are `{KIND_MEMORY}`, `{KIND_FILE}`, `{KIND_HARN_CLOUD_SESSION}`, `{KIND_HARN_CLOUD_ORG}`."
)))
}
fn memory_get(handle_id: &str, key: &str) -> VmValue {
MEMORY_STORE.with(|store| {
let store = store.borrow();
match store.get(handle_id).and_then(|entries| entries.get(key)) {
Some(entry) => json_to_vm_dict(&entry.token),
None => VmValue::Nil,
}
})
}
fn memory_set(handle_id: &str, key: &str, token: JsonValue, ttl_seconds: Option<i64>) {
MEMORY_STORE.with(|store| {
let mut store = store.borrow_mut();
let entry = store.entry(handle_id.to_string()).or_default();
entry.insert(key.to_string(), StoredEntry { token, ttl_seconds });
});
}
fn memory_delete(handle_id: &str, key: &str) {
MEMORY_STORE.with(|store| {
let mut store = store.borrow_mut();
if let Some(entries) = store.get_mut(handle_id) {
entries.remove(key);
if entries.is_empty() {
store.remove(handle_id);
}
}
});
}
fn file_path(handle: &BTreeMap<String, VmValue>) -> Result<PathBuf, VmError> {
handle
.get(HANDLE_KEY_PATH)
.and_then(|value| match value {
VmValue::String(s) => Some(PathBuf::from(s.as_ref())),
_ => None,
})
.ok_or_else(|| VmError::Runtime("oauth storage file handle is missing `path`".to_string()))
}
fn file_secret(handle: &BTreeMap<String, VmValue>) -> Result<Vec<u8>, VmError> {
let id = handle_id(handle)?;
FILE_SECRETS
.with(|secrets| secrets.borrow().get(&id).cloned())
.ok_or_else(|| {
VmError::Runtime(format!(
"oauth storage: encryption key for file handle `{id}` is no longer registered"
))
})
}
fn derive_file_key(secret: &[u8]) -> [u8; 32] {
let hk = Hkdf::<Sha256>::new(None, secret);
let mut out = [0u8; 32];
hk.expand(HKDF_INFO, &mut out)
.expect("HKDF expand for 32 bytes never fails");
out
}
fn read_file_entries(path: &Path, secret: &[u8]) -> Result<BTreeMap<String, StoredEntry>, VmError> {
let raw = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(BTreeMap::new());
}
Err(error) => {
return Err(VmError::Runtime(format!(
"oauth storage: failed to read `{}`: {error}",
path.display()
)));
}
};
if raw.is_empty() {
return Ok(BTreeMap::new());
}
let envelope: FileEnvelope = serde_json::from_slice(&raw).map_err(|error| {
VmError::Runtime(format!(
"oauth storage: file `{}` is not a valid envelope: {error}",
path.display()
))
})?;
if envelope.version != FILE_ENVELOPE_VERSION {
return Err(VmError::Runtime(format!(
"oauth storage: file `{}` envelope version {} not supported",
path.display(),
envelope.version
)));
}
let nonce_bytes = STANDARD_NO_PAD
.decode(envelope.nonce.as_bytes())
.map_err(|error| {
VmError::Runtime(format!("oauth storage: invalid envelope nonce: {error}"))
})?;
if nonce_bytes.len() != 12 {
return Err(VmError::Runtime(format!(
"oauth storage: envelope nonce must be 12 bytes, got {}",
nonce_bytes.len()
)));
}
let ciphertext = STANDARD_NO_PAD
.decode(envelope.ciphertext.as_bytes())
.map_err(|error| {
VmError::Runtime(format!(
"oauth storage: invalid envelope ciphertext: {error}"
))
})?;
let key = derive_file_key(secret);
let cipher = Aes256Gcm::new(AesKey::<Aes256Gcm>::from_slice(&key));
let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = cipher
.decrypt(
nonce,
Payload {
msg: &ciphertext,
aad: FILE_AAD,
},
)
.map_err(|_| {
VmError::Runtime(
"oauth storage: decryption failed; encryption_key does not match the file"
.to_string(),
)
})?;
let map: BTreeMap<String, StoredEntry> =
serde_json::from_slice(&plaintext).map_err(|error| {
VmError::Runtime(format!(
"oauth storage: failed to decode decrypted payload: {error}"
))
})?;
Ok(map)
}
fn write_file_entries(
path: &Path,
secret: &[u8],
entries: &BTreeMap<String, StoredEntry>,
) -> Result<(), VmError> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|error| {
VmError::Runtime(format!(
"oauth storage: failed to create parent directory `{}`: {error}",
parent.display()
))
})?;
}
}
let plaintext = serde_json::to_vec(entries).map_err(|error| {
VmError::Runtime(format!("oauth storage: failed to encode entries: {error}"))
})?;
let key = derive_file_key(secret);
let cipher = Aes256Gcm::new(AesKey::<Aes256Gcm>::from_slice(&key));
let mut nonce_bytes = [0u8; 12];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(
nonce,
Payload {
msg: &plaintext,
aad: FILE_AAD,
},
)
.map_err(|_| VmError::Runtime("oauth storage: AES-GCM encryption failed".to_string()))?;
let envelope = FileEnvelope {
version: FILE_ENVELOPE_VERSION,
nonce: STANDARD_NO_PAD.encode(nonce_bytes),
ciphertext: STANDARD_NO_PAD.encode(&ciphertext),
};
let encoded = serde_json::to_vec(&envelope).map_err(|error| {
VmError::Runtime(format!("oauth storage: failed to encode envelope: {error}"))
})?;
let tmp_path = match path.file_name() {
Some(name) => {
let mut buf = path.to_path_buf();
let mut filename = name.to_os_string();
filename.push(".tmp");
buf.set_file_name(filename);
buf
}
None => {
return Err(VmError::Runtime(format!(
"oauth storage: invalid file path `{}`",
path.display()
)));
}
};
{
let mut file = fs::File::create(&tmp_path).map_err(|error| {
VmError::Runtime(format!(
"oauth storage: failed to open `{}`: {error}",
tmp_path.display()
))
})?;
file.write_all(&encoded).map_err(|error| {
VmError::Runtime(format!(
"oauth storage: failed to write `{}`: {error}",
tmp_path.display()
))
})?;
file.sync_all().map_err(|error| {
VmError::Runtime(format!(
"oauth storage: failed to sync `{}`: {error}",
tmp_path.display()
))
})?;
}
fs::rename(&tmp_path, path).map_err(|error| {
VmError::Runtime(format!(
"oauth storage: failed to rename `{}` to `{}`: {error}",
tmp_path.display(),
path.display()
))
})
}
const FILE_AAD: &[u8] = HKDF_INFO;
fn file_get(handle: &BTreeMap<String, VmValue>, key: &str) -> Result<VmValue, VmError> {
let path = file_path(handle)?;
let secret = file_secret(handle)?;
let entries = read_file_entries(&path, &secret)?;
Ok(match entries.get(key) {
Some(entry) => json_to_vm_dict(&entry.token),
None => VmValue::Nil,
})
}
fn file_set(
handle: &BTreeMap<String, VmValue>,
key: &str,
token: JsonValue,
ttl_seconds: Option<i64>,
) -> Result<(), VmError> {
let path = file_path(handle)?;
let secret = file_secret(handle)?;
let mut entries = read_file_entries(&path, &secret)?;
entries.insert(key.to_string(), StoredEntry { token, ttl_seconds });
write_file_entries(&path, &secret, &entries)
}
fn file_delete(handle: &BTreeMap<String, VmValue>, key: &str) -> Result<(), VmError> {
let path = file_path(handle)?;
let secret = file_secret(handle)?;
let mut entries = read_file_entries(&path, &secret)?;
if entries.remove(key).is_none() {
return Ok(());
}
if entries.is_empty() {
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(VmError::Runtime(format!(
"oauth storage: failed to remove `{}`: {error}",
path.display()
))),
}
} else {
write_file_entries(&path, &secret, &entries)
}
}
fn cloud_scope(handle: &BTreeMap<String, VmValue>) -> Result<String, VmError> {
handle
.get(HANDLE_KEY_SCOPE)
.and_then(|value| match value {
VmValue::String(s) => Some(s.to_string()),
_ => None,
})
.ok_or_else(|| {
VmError::Runtime("oauth storage cloud handle is missing `scope`".to_string())
})
}
async fn cloud_get(handle: &BTreeMap<String, VmValue>, key: &str) -> Result<VmValue, VmError> {
let scope = cloud_scope(handle)?;
let mut params = BTreeMap::new();
params.insert("scope".to_string(), string_value(&scope));
params.insert("key".to_string(), string_value(key));
dispatch_host_operation("oauth_storage", "cloud_get", ¶ms).await
}
async fn cloud_set(
handle: &BTreeMap<String, VmValue>,
key: &str,
token: JsonValue,
ttl_seconds: Option<i64>,
) -> Result<(), VmError> {
let scope = cloud_scope(handle)?;
let mut params = BTreeMap::new();
params.insert("scope".to_string(), string_value(&scope));
params.insert("key".to_string(), string_value(key));
params.insert("token".to_string(), json_to_vm_dict(&token));
if let Some(ttl) = ttl_seconds {
params.insert("ttl_seconds".to_string(), VmValue::Int(ttl));
}
dispatch_host_operation("oauth_storage", "cloud_set", ¶ms).await?;
Ok(())
}
async fn cloud_delete(handle: &BTreeMap<String, VmValue>, key: &str) -> Result<(), VmError> {
let scope = cloud_scope(handle)?;
let mut params = BTreeMap::new();
params.insert("scope".to_string(), string_value(&scope));
params.insert("key".to_string(), string_value(key));
dispatch_host_operation("oauth_storage", "cloud_delete", ¶ms).await?;
Ok(())
}
fn json_to_vm_dict(value: &JsonValue) -> VmValue {
crate::schema::json_to_vm_value(value)
}
fn require_handle(
args: &[VmValue],
index: usize,
fn_name: &str,
) -> Result<BTreeMap<String, VmValue>, VmError> {
match args.get(index) {
Some(VmValue::Dict(dict)) => Ok(dict.as_ref().clone()),
Some(other) => Err(VmError::Runtime(format!(
"{fn_name}: handle argument must be a dict, got {}",
other.type_name()
))),
None => Err(VmError::Runtime(format!(
"{fn_name}: missing handle argument"
))),
}
}
fn required_string_arg(
args: &[VmValue],
index: usize,
fn_name: &str,
arg_name: &str,
) -> Result<String, VmError> {
match args.get(index) {
Some(VmValue::String(s)) => Ok(s.to_string()),
Some(other) => Err(VmError::Runtime(format!(
"{fn_name}: `{arg_name}` must be a string, got {}",
other.type_name()
))),
None => Err(VmError::Runtime(format!(
"{fn_name}: `{arg_name}` argument is required"
))),
}
}
fn required_bytes_or_string(
args: &[VmValue],
index: usize,
fn_name: &str,
) -> Result<Vec<u8>, VmError> {
match args.get(index) {
Some(VmValue::Bytes(bytes)) => {
if bytes.is_empty() {
Err(VmError::Runtime(format!(
"{fn_name}: `encryption_key` must not be empty"
)))
} else {
Ok(bytes.as_ref().clone())
}
}
Some(VmValue::String(s)) => {
if s.is_empty() {
Err(VmError::Runtime(format!(
"{fn_name}: `encryption_key` must not be empty"
)))
} else {
Ok(s.as_bytes().to_vec())
}
}
Some(other) => Err(VmError::Runtime(format!(
"{fn_name}: `encryption_key` must be bytes or string, got {}",
other.type_name()
))),
None => Err(VmError::Runtime(format!(
"{fn_name}: `encryption_key` argument is required"
))),
}
}
fn require_dict_arg(
args: &[VmValue],
index: usize,
fn_name: &str,
arg_name: &str,
) -> Result<BTreeMap<String, VmValue>, VmError> {
match args.get(index) {
Some(VmValue::Dict(dict)) => Ok(dict.as_ref().clone()),
Some(other) => Err(VmError::Runtime(format!(
"{fn_name}: `{arg_name}` must be a dict, got {}",
other.type_name()
))),
None => Err(VmError::Runtime(format!(
"{fn_name}: `{arg_name}` argument is required"
))),
}
}
fn optional_int_arg(
args: &[VmValue],
index: usize,
fn_name: &str,
arg_name: &str,
) -> Result<Option<i64>, VmError> {
match args.get(index) {
Some(VmValue::Nil) | None => Ok(None),
Some(VmValue::Int(value)) => Ok(Some(*value)),
Some(VmValue::Duration(value)) => Ok(Some(*value / 1000)),
Some(other) => Err(VmError::Runtime(format!(
"{fn_name}: `{arg_name}` must be int or duration, got {}",
other.type_name()
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::value::values_equal;
fn token_dict(access: &str) -> BTreeMap<String, VmValue> {
let mut dict = BTreeMap::new();
dict.insert("access_token".to_string(), string_value(access));
dict
}
fn dict_from_vm(value: &VmValue) -> BTreeMap<String, VmValue> {
match value {
VmValue::Dict(dict) => dict.as_ref().clone(),
other => panic!("expected dict, got {}", other.type_name()),
}
}
fn assert_access_token(value: &VmValue, expected: &str) {
let dict = dict_from_vm(value);
let access = dict
.get("access_token")
.unwrap_or_else(|| panic!("missing access_token field"));
assert!(
values_equal(access, &string_value(expected)),
"expected access_token=`{expected}`, got {access:?}"
);
}
#[tokio::test]
async fn memory_backend_roundtrips() {
let handle = match memory_handle() {
VmValue::Dict(dict) => dict.as_ref().clone(),
other => panic!("expected dict handle, got {other:?}"),
};
backend_set(&handle, "primary", &token_dict("hello"), Some(3600))
.await
.unwrap();
let fetched = backend_get(&handle, "primary").await.unwrap();
assert_access_token(&fetched, "hello");
backend_delete(&handle, "primary").await.unwrap();
let after = backend_get(&handle, "primary").await.unwrap();
assert!(matches!(after, VmValue::Nil));
}
#[tokio::test]
async fn file_backend_encrypts_at_rest() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("tokens.bin");
let handle = match file_handle(path.to_str().unwrap(), b"correct-horse-battery-staple") {
VmValue::Dict(dict) => dict.as_ref().clone(),
other => panic!("expected dict handle, got {other:?}"),
};
backend_set(&handle, "k", &token_dict("super-secret"), None)
.await
.unwrap();
let raw = std::fs::read(&path).unwrap();
let envelope: FileEnvelope = serde_json::from_slice(&raw).unwrap();
assert_eq!(envelope.version, FILE_ENVELOPE_VERSION);
assert!(!String::from_utf8_lossy(&raw).contains("super-secret"));
let fetched = backend_get(&handle, "k").await.unwrap();
assert_access_token(&fetched, "super-secret");
backend_delete(&handle, "k").await.unwrap();
let after = backend_get(&handle, "k").await.unwrap();
assert!(matches!(after, VmValue::Nil));
assert!(!path.exists());
}
#[tokio::test]
async fn file_backend_rejects_wrong_key() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("tokens.bin");
let good = match file_handle(path.to_str().unwrap(), b"good-secret") {
VmValue::Dict(dict) => dict.as_ref().clone(),
other => panic!("expected dict, got {other:?}"),
};
backend_set(&good, "k", &token_dict("payload"), None)
.await
.unwrap();
let bad = match file_handle(path.to_str().unwrap(), b"bad-secret") {
VmValue::Dict(dict) => dict.as_ref().clone(),
other => panic!("expected dict, got {other:?}"),
};
let result = backend_get(&bad, "k").await;
assert!(result.is_err(), "expected decryption error, got {result:?}");
}
}