Files
inbuxa-server/crates/dav/src/common/lock.rs
T
jcoffey-dev cc6f1eb298
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 7m53s
Rename the identifiers that carried the upstream name
Everything clients, users and operators meet now carries the fork's name,
with no aliases (SPEC.md §2.4, changed here from "protocol identifiers
stay"):

- JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside
  the fork's own urn:inbuxa:jmap.
- WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once.
- Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these
  into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in;
  a unit test fails if Cargo.lock ever moves past the vendored copy. The
  trusted runtime now names itself too, rather than answering sieve-rs's
  default.
- The web interface's OAuth client is inbuxa-webui. On every start the old
  stalwart-webui client is removed and any application naming it is moved
  over.
- The spam filter's blobs are INBUXA_SPAM_*; every start moves any left
  under the old keys, so a trained model survives.
- SQL stores and log files default to inbuxa, in the code and in the
  schema served to the admin (checksum regenerated).
- Settings are INBUXA_* only. A STALWART_* variable that's set where its
  INBUXA_* one isn't stops the server at startup, naming it.
- The version-upgrade messages link docs.inbuxa.org's migration page, and
  the OpenAPI description, smtp crate metadata and web-push test fixtures
  lose the name.

Kept on purpose, allowlisted with reasons: the OAuth key-derivation
contexts (renaming them would end every session and invalidate every
sealed client id) and the hashed application prefix.

Also fixes a latent start-up failure: ensure_client updated an existing
first-party client with a revision of 0, which the registry's assertion
never matches, so adding a redirect URI or changing the webmail secret
failed start-up. And the principal session test now expects
legacyProtocols (C-1, added 2026-09-21), which it had missed.

Tested: the server builds without warnings; common's 106 unit tests,
including the vendoring check; a new integration test for the two
start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
2026-09-22 19:33:02 -07:00

895 lines
30 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use super::ETag;
use super::uri::{DavUriResource, OwnedUri, UriResource, Urn};
use crate::{DavError, DavErrorCondition, DavMethod};
use common::KV_LOCK_DAV;
use common::{Server, auth::AccessToken};
use dav_proto::schema::property::{ActiveLock, LockScope, WebDavProperty};
use dav_proto::schema::request::DavPropertyValue;
use dav_proto::schema::response::{BaseCondition, List, PropResponse};
use dav_proto::{Condition, Depth, Timeout};
use dav_proto::{RequestHeaders, schema::request::LockInfo};
use groupware::cache::GroupwareCache;
use http_proto::HttpResponse;
use hyper::StatusCode;
use std::collections::HashMap;
use store::ValueKey;
use store::dispatch::lookup::KeyValue;
use store::write::serialize::rkyv_deserialize;
use store::write::{AlignedBytes, Archive, Archiver, now};
use store::{Serialize, U32_LEN};
use trc::AddContext;
use types::collection::Collection;
use types::dead_property::DeadProperty;
#[derive(Debug, Default, Clone)]
pub struct ResourceState<'x> {
pub account_id: u32,
pub collection: Collection,
pub document_id: Option<u32>,
pub etag: Option<String>,
pub lock_tokens: Vec<String>,
pub sync_token: Option<String>,
pub path: &'x str,
}
#[derive(Debug, Default, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub(crate) struct LockData {
locks: HashMap<String, LockItems>,
}
#[derive(Debug, Default, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
#[repr(transparent)]
pub(crate) struct LockItems(Vec<LockItem>);
#[derive(Debug, Default, Clone, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub(crate) struct LockItem {
lock_id: u64,
owner: u32,
expires: u64,
depth_infinity: bool,
exclusive: bool,
owner_dav: Option<DeadProperty>,
}
struct LockCache<'x> {
account_id: u32,
collection: Collection,
lock_archive: LockArchive<'x>,
}
enum LockArchive<'x> {
Unarchived(&'x ArchivedLockData),
Archived(Archive<AlignedBytes>),
}
#[derive(Default)]
pub(crate) struct LockCaches<'x> {
caches: Vec<LockCache<'x>>,
}
pub(crate) trait LockRequestHandler: Sync + Send {
fn handle_lock_request(
&self,
access_token: &AccessToken,
headers: &RequestHeaders<'_>,
lock_info: LockRequest,
) -> impl Future<Output = crate::Result<HttpResponse>> + Send;
fn validate_headers(
&self,
access_token: &AccessToken,
headers: &RequestHeaders<'_>,
resources: Vec<ResourceState<'_>>,
locks: LockCaches<'_>,
method: DavMethod,
) -> impl Future<Output = crate::Result<()>> + Send;
}
pub(crate) enum LockRequest {
Lock(LockInfo),
Unlock,
Refresh,
}
impl LockRequestHandler for Server {
async fn handle_lock_request(
&self,
access_token: &AccessToken,
headers: &RequestHeaders<'_>,
lock_info: LockRequest,
) -> crate::Result<HttpResponse> {
let resource = self
.validate_uri(access_token, headers.uri)
.await?
.into_owned_uri()?;
let resource_hash = resource.lock_key();
let resource_path = resource
.resource
.ok_or(DavError::Code(StatusCode::CONFLICT))?;
let account_id = resource.account_id;
if !access_token.is_member(account_id) {
return Err(DavError::Code(StatusCode::FORBIDDEN));
}
let resources = vec![ResourceState {
account_id,
collection: resource.collection,
path: resource_path,
..Default::default()
}];
let mut base_path = None;
let is_lock_request = !matches!(lock_info, LockRequest::Unlock);
let if_lock_token = headers
.if_
.iter()
.flat_map(|if_| if_.list.iter())
.find_map(|cond| {
if let Condition::StateToken { token, .. } = cond {
Urn::parse(token).and_then(|u| u.try_unwrap_lock())
} else {
None
}
})
.unwrap_or_default();
let mut lock_data = if let Some(lock_data) = self
.in_memory_store()
.key_get::<Archive<AlignedBytes>>(resource_hash.as_slice())
.await
.caused_by(trc::location!())?
{
let lock_data = lock_data
.unarchive::<LockData>()
.caused_by(trc::location!())?;
self.validate_headers(
access_token,
headers,
resources,
LockCaches::new_shared(account_id, resource.collection, lock_data),
if is_lock_request {
DavMethod::LOCK
} else {
DavMethod::UNLOCK
},
)
.await?;
if let LockRequest::Lock(lock_info) = &lock_info {
let mut failed_locks = Vec::new();
let is_exclusive = matches!(lock_info.lock_scope, LockScope::Exclusive);
let is_infinity = matches!(headers.depth, Depth::Infinity);
for (lock_path, lock_item) in lock_data.find_locks(resource_path, true) {
if if_lock_token != lock_item.lock_id
&& (lock_item.exclusive || is_exclusive)
&& (lock_path.len() == resource_path.len()
|| lock_item.depth_infinity && resource_path.len() > lock_path.len()
|| is_infinity && lock_path.len() > resource_path.len())
{
let base_path =
base_path.get_or_insert_with(|| headers.base_uri().unwrap_or_default());
failed_locks.push(format!("{base_path}/{lock_path}").into());
}
}
if !failed_locks.is_empty() {
return Err(DavErrorCondition::new(
StatusCode::LOCKED,
BaseCondition::LockTokenSubmitted(List(failed_locks)),
)
.into());
}
// Validate lock_info
if lock_info.owner.as_ref().is_some_and(|o| {
o.size() > self.core.groupware.dead_property_size.unwrap_or(512)
}) {
return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE));
}
if self.core.groupware.max_locks_per_user > 0
&& lock_data
.locks
.values()
.flat_map(|locks| {
locks
.0
.iter()
.filter(|lock| lock.owner == access_token.account_id())
})
.count()
>= self.core.groupware.max_locks_per_user
{
return Err(DavError::Code(StatusCode::TOO_MANY_REQUESTS));
}
}
rkyv_deserialize(lock_data).caused_by(trc::location!())?
} else if is_lock_request {
self.validate_headers(
access_token,
headers,
resources,
Default::default(),
DavMethod::LOCK,
)
.await?;
LockData::default()
} else {
return Err(DavErrorCondition::new(
StatusCode::CONFLICT,
BaseCondition::LockTokenMatchesRequestUri,
)
.into());
};
let now = now();
let response = if is_lock_request {
let timeout = if let Timeout::Second(seconds) = headers.timeout {
std::cmp::min(seconds, self.core.groupware.max_lock_timeout)
} else {
self.core.groupware.max_lock_timeout
};
let expires = now + timeout;
let lock_item = if if_lock_token > 0 {
if let Some(lock_item) = lock_data
.locks
.values_mut()
.flat_map(|locks| locks.0.iter_mut())
.find(|lock| lock.lock_id == if_lock_token)
{
lock_item
} else {
return Err(DavError::Code(StatusCode::PRECONDITION_FAILED));
}
} else {
let locks = lock_data
.locks
.entry(resource_path.to_string())
.or_insert_with(Default::default);
locks.0.push(LockItem::default());
locks.0.last_mut().unwrap()
};
lock_item.expires = expires;
if let LockRequest::Lock(lock_info) = lock_info {
// Validate lock_info
if lock_info.owner.as_ref().is_some_and(|o| {
o.size() > self.core.groupware.dead_property_size.unwrap_or(512)
}) {
return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE));
}
lock_item.lock_id = store::rand::random::<u64>() ^ expires;
lock_item.owner = access_token.account_id();
lock_item.depth_infinity = matches!(headers.depth, Depth::Infinity);
lock_item.owner_dav = lock_info.owner;
lock_item.exclusive = matches!(lock_info.lock_scope, LockScope::Exclusive);
}
let base_path = base_path.get_or_insert_with(|| headers.base_uri().unwrap_or_default());
let active_lock = lock_item.to_active_lock(format!("{base_path}/{resource_path}"));
HttpResponse::new(if if_lock_token == 0 {
StatusCode::CREATED
} else {
StatusCode::OK
})
.with_lock_token(&active_lock.lock_token.as_ref().unwrap().0)
.with_xml_body(
PropResponse::new(vec![DavPropertyValue::new(
WebDavProperty::LockDiscovery,
vec![active_lock],
)])
.to_string(),
)
} else {
let lock_id = headers
.lock_token
.and_then(Urn::parse)
.and_then(|urn| urn.try_unwrap_lock())
.ok_or(DavError::Code(StatusCode::BAD_REQUEST))?;
if lock_data.remove_lock(lock_id) {
HttpResponse::new(StatusCode::NO_CONTENT)
} else {
return Err(DavErrorCondition::new(
StatusCode::CONFLICT,
BaseCondition::LockTokenMatchesRequestUri,
)
.into());
}
};
// Remove expired locks
let max_expire = lock_data.remove_expired();
if max_expire > 0 {
self.in_memory_store()
.key_set(
KeyValue::new(
resource_hash,
Archiver::new(lock_data)
.untrusted()
.serialize()
.caused_by(trc::location!())?,
)
.expires(max_expire),
)
.await
.caused_by(trc::location!())?;
} else {
self.in_memory_store()
.key_delete(resource_hash)
.await
.caused_by(trc::location!())?;
}
Ok(response)
}
async fn validate_headers(
&self,
access_token: &AccessToken,
headers: &RequestHeaders<'_>,
mut resources: Vec<ResourceState<'_>>,
mut locks_: LockCaches<'_>,
method: DavMethod,
) -> crate::Result<()> {
let no_if_headers = headers.if_.is_empty();
match method {
DavMethod::GET | DavMethod::HEAD if no_if_headers => {
// Return early for GET/HEAD requests without If headers
return Ok(());
}
DavMethod::COPY
| DavMethod::MOVE
| DavMethod::POST
| DavMethod::PUT
| DavMethod::PATCH
if headers.overwrite_fail
&& resources.last().is_some_and(|r| {
r.etag.is_some() || r.document_id.is_some_and(|id| id != u32::MAX)
}) =>
{
return Err(DavError::Code(StatusCode::PRECONDITION_FAILED));
}
_ => {}
}
// Add lock data to the cache
for resource in &resources {
if locks_.is_cached(resource).is_none() {
locks_.insert_lock_data(self, resource).await?;
}
}
// Unarchive lock data
let mut locks = locks_.to_unarchived().caused_by(trc::location!())?;
// Validate locks for write operations
let mut lock_response = Ok(());
if !matches!(
method,
DavMethod::GET | DavMethod::HEAD | DavMethod::LOCK | DavMethod::UNLOCK
) {
let mut base_path = None;
'outer: for (pos, resource) in resources.iter().enumerate() {
if pos == 0 && matches!(method, DavMethod::COPY) {
continue;
}
if let Some(idx) = locks.find_cache_pos(self, resource).await? {
let mut failed_locks = Vec::new();
for (lock_path, lock_item) in locks.find_locks_by_pos(idx, resource, true)? {
let lock_token = lock_item.urn().to_string();
if headers.if_.iter().any(|if_| {
if_.resource
.is_none_or(|r| {
r.trim_end_matches('/').ends_with(lock_path)})
&& if_.list.iter().any(|cond| matches!(cond, Condition::StateToken { token, .. } if token == &lock_token))
}) {
break 'outer;
} else {
let base_path = base_path.get_or_insert_with(|| {
headers.base_uri()
.unwrap_or_default()
});
failed_locks.push(format!("{base_path}/{lock_path}").into());
}
}
if !failed_locks.is_empty() {
lock_response = Err(DavErrorCondition::new(
StatusCode::LOCKED,
BaseCondition::LockTokenSubmitted(List(failed_locks)),
)
.into());
break;
}
}
}
}
// There are no If headers, so we can return early
if no_if_headers {
return lock_response;
}
let mut resource_not_found = ResourceState {
account_id: u32::MAX,
collection: Collection::None,
path: "",
..Default::default()
};
'outer: for if_ in &headers.if_ {
if if_.list.is_empty() {
continue;
}
let mut resource_state = &mut resource_not_found;
if let Some(resource) = if_.resource {
if let Some(resource) = self
.validate_uri(access_token, resource)
.await
.ok()
.and_then(|r| {
let path = r.resource?;
Some(ResourceState {
account_id: r.account_id?,
collection: if !matches!(r.collection, Collection::FileNode)
&& path.contains('/')
{
r.collection.child_collection().unwrap_or(r.collection)
} else {
r.collection
},
path,
..Default::default()
})
})
{
if let Some(known_resource) = resources.iter_mut().find(|r| {
r.account_id == resource.account_id
&& r.collection == resource.collection
&& r.path == resource.path
}) {
resource_state = known_resource;
} else if access_token.has_access(resource.account_id, resource.collection) {
resources.push(resource);
resource_state = resources.last_mut().unwrap();
}
}
} else if let Some(resource) = resources.first_mut() {
resource_state = resource;
};
// Fill missing data for resource
if resource_state.collection != Collection::None
&& (resource_state.etag.is_none()
|| resource_state.lock_tokens.is_empty()
|| resource_state.sync_token.is_none())
{
let mut needs_lock_token = false;
let mut needs_sync_token = false;
let mut needs_etag = false;
for cond in &if_.list {
match cond {
Condition::StateToken { token, .. } => {
if token.starts_with("urn:inbuxa:davsync:") {
needs_sync_token = true;
} else {
needs_lock_token = true;
}
}
Condition::ETag { .. } | Condition::Exists { .. } => {
needs_etag = true;
}
}
}
// Fetch eTag
if needs_etag && resource_state.etag.is_none() {
if resource_state.document_id.is_none() {
resource_state.document_id = self
.map_uri_resource(
access_token,
UriResource {
collection: resource_state.collection,
account_id: resource_state.account_id,
resource: resource_state.path.into(),
},
)
.await
.caused_by(trc::location!())?
.map(|uri| uri.resource)
.unwrap_or(u32::MAX)
.into();
}
if let Some(document_id) =
resource_state.document_id.filter(|&id| id != u32::MAX)
&& let Some(archive) = self
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
resource_state.account_id,
resource_state.collection,
document_id,
))
.await
.caused_by(trc::location!())?
{
resource_state.etag = archive.etag().into();
}
}
// Fetch lock token
if needs_lock_token
&& resource_state.lock_tokens.is_empty()
&& let Some(idx) = locks.find_cache_pos(self, resource_state).await?
{
let found_locks = locks
.find_locks_by_pos(idx, resource_state, false)?
.iter()
.map(|(_, lock)| lock.urn().to_string())
.collect::<Vec<_>>();
resource_state.lock_tokens = found_locks;
}
// Fetch sync token
if needs_sync_token && resource_state.sync_token.is_none() {
let id = self
.fetch_dav_resources(
access_token.account_id(),
resource_state.account_id,
resource_state.collection.into(),
)
.await
.caused_by(trc::location!())?
.highest_change_id;
resource_state.sync_token = Some(Urn::Sync { id, seq: 0 }.to_string());
}
}
for cond in &if_.list {
match cond {
Condition::StateToken { is_not, token } => {
if let Some(token) = Urn::try_extract_sync_id(token) {
if !((resource_state
.sync_token
.as_deref()
.and_then(Urn::try_extract_sync_id)
.is_some_and(|sync_token| sync_token == token))
^ is_not)
{
continue 'outer;
}
} else if !((resource_state.lock_tokens.iter().any(|t| t == token))
^ is_not)
{
continue 'outer;
}
}
Condition::ETag { is_not, tag } => {
if !((resource_state.etag.as_ref().is_some_and(|etag| etag == tag))
^ is_not)
{
continue 'outer;
}
}
Condition::Exists { is_not } => {
if !((resource_state.etag.is_some()) ^ is_not) {
continue 'outer;
}
}
}
}
return lock_response;
}
Err(DavError::Code(
if matches!(method, DavMethod::GET | DavMethod::HEAD)
&& headers
.if_
.iter()
.any(|if_| if_.list.iter().any(|cond| cond.is_none_match()))
{
StatusCode::NOT_MODIFIED
} else {
StatusCode::PRECONDITION_FAILED
},
))
}
}
impl LockData {
pub fn remove_lock(&mut self, lock_id: u64) -> bool {
for (lock_path, lock_items) in self.locks.iter_mut() {
for (idx, lock_item) in lock_items.0.iter().enumerate() {
if lock_item.lock_id == lock_id {
lock_items.0.swap_remove(idx);
if lock_items.0.is_empty() {
let lock_path = lock_path.clone();
self.locks.remove(&lock_path);
}
return true;
}
}
}
false
}
pub fn remove_expired(&mut self) -> u64 {
let mut max_expire = 0;
let now = now();
self.locks.retain(|_, locks| {
locks.0.retain(|lock| {
if lock.expires > now {
max_expire = std::cmp::max(max_expire, lock.expires);
true
} else {
false
}
});
!locks.0.is_empty()
});
max_expire
}
}
impl<'x> LockArchive<'x> {
fn unarchive(&'x self) -> trc::Result<&'x ArchivedLockData> {
match self {
LockArchive::Unarchived(archived_lock_data) => Ok(archived_lock_data),
LockArchive::Archived(archive) => {
archive.unarchive::<LockData>().caused_by(trc::location!())
}
}
}
}
impl<'x> LockCaches<'x> {
pub(self) fn new_shared(
account_id: u32,
collection: Collection,
lock_data: &'x ArchivedLockData,
) -> Self {
Self {
caches: vec![LockCache {
account_id,
collection,
lock_archive: LockArchive::Unarchived(lock_data),
}],
}
}
pub fn to_unarchived(&'x self) -> trc::Result<LockCaches<'x>> {
let caches = self
.caches
.iter()
.map(|cache| {
Ok(LockCache {
account_id: cache.account_id,
collection: cache.collection,
lock_archive: LockArchive::Unarchived(
cache.lock_archive.unarchive().caused_by(trc::location!())?,
),
})
})
.collect::<trc::Result<Vec<_>>>()?;
Ok(LockCaches { caches })
}
#[inline]
pub fn is_cached(&self, resource_state: &ResourceState<'_>) -> Option<usize> {
self.caches.iter().position(|cache| {
resource_state.account_id == cache.account_id
&& resource_state.collection.main_collection() == cache.collection.main_collection()
})
}
pub async fn find_cache_pos(
&mut self,
server: &Server,
resource_state: &ResourceState<'_>,
) -> trc::Result<Option<usize>> {
if let Some(idx) = self.is_cached(resource_state) {
Ok(Some(idx))
} else if resource_state.collection != Collection::None {
if self.insert_lock_data(server, resource_state).await? {
Ok(Some(self.caches.len() - 1))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
fn find_locks_by_pos(
&'x self,
pos: usize,
resource_state: &'x ResourceState<'_>,
include_children: bool,
) -> trc::Result<Vec<(&'x str, &'x ArchivedLockItem)>> {
self.caches[pos]
.lock_archive
.unarchive()
.map(|l| l.find_locks(resource_state.path, include_children))
}
async fn insert_lock_data(
&mut self,
server: &Server,
resource_state: &ResourceState<'_>,
) -> trc::Result<bool> {
if let Some(lock_archive) = server
.in_memory_store()
.key_get::<Archive<AlignedBytes>>(resource_state.lock_key().as_slice())
.await
.caused_by(trc::location!())?
{
self.caches.push(LockCache {
account_id: resource_state.account_id,
collection: resource_state.collection,
lock_archive: LockArchive::Archived(lock_archive),
});
Ok(true)
} else {
Ok(false)
}
}
}
impl LockItem {
pub fn to_active_lock(&self, href: String) -> ActiveLock {
ActiveLock::new(
href,
if self.exclusive {
LockScope::Exclusive
} else {
LockScope::Shared
},
)
.with_depth(if self.depth_infinity {
Depth::Infinity
} else {
Depth::Zero
})
.with_owner_opt(self.owner_dav.clone())
.with_timeout(self.expires.saturating_sub(now()))
.with_lock_token(self.urn().to_string())
}
pub fn urn(&self) -> Urn {
Urn::Lock(self.lock_id)
}
}
impl ArchivedLockData {
pub fn find_locks<'x: 'y, 'y>(
&'x self,
resource: &'y str,
include_children: bool,
) -> Vec<(&'y str, &'x ArchivedLockItem)> {
let now = now();
let mut resource_part = resource;
let mut found_locks = Vec::new();
loop {
if let Some(locks) = self.locks.get(resource_part) {
found_locks.extend(
locks
.0
.iter()
.filter(|lock| {
lock.expires > now && (resource == resource_part || lock.depth_infinity)
})
.map(|lock| (resource_part, lock)),
);
}
if let Some((resource_part_, _)) = resource_part.rsplit_once('/') {
resource_part = resource_part_;
} else {
break;
}
}
if include_children {
let prefix = format!("{}/", resource);
for (resource_part, locks) in self.locks.iter() {
if resource_part.starts_with(&prefix) {
found_locks.extend(
locks
.0
.iter()
.filter(|lock| lock.expires > now)
.map(|lock| (resource_part.as_str(), lock)),
);
}
}
}
found_locks
}
}
impl ArchivedLockItem {
pub fn to_active_lock(&self, href: String) -> ActiveLock {
ActiveLock::new(
href,
if self.exclusive {
LockScope::Exclusive
} else {
LockScope::Shared
},
)
.with_depth(if self.depth_infinity {
Depth::Infinity
} else {
Depth::Zero
})
.with_owner_opt(self.owner_dav.as_ref().map(Into::into))
.with_timeout(u64::from(self.expires).saturating_sub(now()))
.with_lock_token(self.urn().to_string())
}
pub fn urn(&self) -> Urn {
Urn::Lock(self.lock_id.into())
}
}
impl OwnedUri<'_> {
pub fn lock_key(&self) -> Vec<u8> {
build_lock_key(self.account_id, self.collection.main_collection())
}
}
impl ResourceState<'_> {
pub fn lock_key(&self) -> Vec<u8> {
build_lock_key(self.account_id, self.collection.main_collection())
}
}
pub(crate) fn build_lock_key(account_id: u32, collection: Collection) -> Vec<u8> {
let mut result = Vec::with_capacity(U32_LEN + 2);
result.push(KV_LOCK_DAV);
result.extend_from_slice(account_id.to_be_bytes().as_slice());
result.push(u8::from(collection));
result
}
impl PartialEq for ResourceState<'_> {
fn eq(&self, other: &Self) -> bool {
self.account_id == other.account_id
&& self.collection == other.collection
&& self.document_id == other.document_id
}
}
impl Eq for ResourceState<'_> {}