mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
sftp: a sealed file opens while the vault is open
Decrypted a record at a time as it is read, from any offset, and sealed again when it is written into or over, in a sealed folder or not. Shut, the vault refuses the open and cuts off reads already under way. Reads over the mount do not count as use, so a mount alone does not keep the vault open.
This commit is contained in:
1 parent
fc593e6452
commit
e4e3e2b5aa
3 files changed
+345
-34
No files matched your search
@@ -267,9 +267,14 @@ limit at least that large too.
|
||||
The SFTP mount shows files beside the documents, under their own names — a
|
||||
later file that shares one is told apart by its id, `slides (42).pdf` — and
|
||||
anything copied in that is not a `.md` becomes a file in the tree, streamed to
|
||||
disk under the same size limit. A sealed file is listed but does not open
|
||||
there. A file copied into a sealed folder is sealed while the vault is open in
|
||||
the browser, and refused while it is shut. Editor droppings — swap files, `~` backups, dotted temp files — stay in
|
||||
disk under the same size limit. While the vault is open in the browser a
|
||||
sealed file opens there too, decrypted as it is read, and is sealed again when
|
||||
it is saved, wherever it sits; a file copied into a sealed folder is sealed on
|
||||
the way in. While the vault is shut both are refused, and locking it cuts off
|
||||
a read already under way — though a client that cached what it read keeps
|
||||
that copy until it lets go of it. Reading over the
|
||||
mount does not hold the vault open; only the browser and opening a file do.
|
||||
Editor droppings — swap files, `~` backups, dotted temp files — stay in
|
||||
memory for the session, and a dotted temp file renamed onto a real name is kept
|
||||
as that file, which is how `rsync` and most editors save.
|
||||
|
||||
|
||||
+329
-31
@@ -8,10 +8,12 @@
|
||||
//! A document reads as `# <title>` followed by its body, the same shape the
|
||||
//! export writes, and a leading heading written back becomes the title again.
|
||||
//! A file is streamed from `uploads/` and written back to it through a
|
||||
//! `.part`, never held in memory. A sealed file is listed but cannot be
|
||||
//! opened here: the vault is opened in the browser, for the browser. A file
|
||||
//! written into a sealed folder, or moved into one, is sealed while the vault
|
||||
//! is open there and refused while it is shut.
|
||||
//! `.part`, never held in memory. A sealed file opens here while the vault is
|
||||
//! open in the browser, decrypted a record at a time as it is read, and is
|
||||
//! sealed again when it is written over; while the vault is shut it is listed
|
||||
//! but refused, and locking the vault cuts off a read already under way. A
|
||||
//! file written into a sealed folder, or moved into one, is sealed while the
|
||||
//! vault is open and refused while it is shut.
|
||||
//!
|
||||
//! What an editor writes next to a document — vim's `.swp` and `4913` probe,
|
||||
//! a `foo.md~` backup, a dotted temp file it renames over the original — lives
|
||||
@@ -24,6 +26,7 @@ use russh_sftp::protocol::{File, FileAttributes, Handle, Name, OpenFlags, Status
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::{
|
||||
backup::{
|
||||
@@ -212,17 +215,86 @@ enum OpenHandle {
|
||||
Write { id: i64, buf: Vec<u8>, base: i64 },
|
||||
/// A file node's bytes, read from disk as asked for.
|
||||
ReadFile { file: tokio::fs::File, size: u64 },
|
||||
/// A sealed file node's plaintext, decrypted as asked for.
|
||||
ReadSealed(Box<SealedRead>),
|
||||
/// A file being written to `uploads/<id>.part`, stored on close.
|
||||
Upload(Upload),
|
||||
/// A scratch file being written, stored back under `path` on close.
|
||||
Scratch { path: String, buf: Vec<u8> },
|
||||
}
|
||||
|
||||
/// A sealed file open for reading. Reads come at any offset, so each opens
|
||||
/// the record it falls in; the last one is kept for the reads after it,
|
||||
/// which usually ask for the rest of it.
|
||||
struct SealedRead {
|
||||
file: tokio::fs::File,
|
||||
opener: vault::Opener,
|
||||
/// The plaintext's size, which is what the listing shows.
|
||||
size: u64,
|
||||
record: Option<(u64, Zeroizing<Vec<u8>>)>,
|
||||
}
|
||||
|
||||
impl SealedRead {
|
||||
async fn read(&mut self, offset: u64, len: u32) -> SftpResult<Vec<u8>> {
|
||||
if offset >= self.size {
|
||||
return Err(StatusCode::Eof);
|
||||
}
|
||||
let end = offset + (len as u64).min(self.size - offset);
|
||||
let mut data = Vec::with_capacity((end - offset) as usize);
|
||||
let mut at = offset;
|
||||
while at < end {
|
||||
let index = at / vault::CHUNK as u64;
|
||||
if !matches!(&self.record, Some((open, _)) if *open == index) {
|
||||
self.record = Some((index, self.open_record(index).await?));
|
||||
}
|
||||
let Some((_, plain)) = &self.record else {
|
||||
unreachable!("just opened")
|
||||
};
|
||||
let start = (at - index * vault::CHUNK as u64) as usize;
|
||||
let take = ((end - at) as usize).min(plain.len().saturating_sub(start));
|
||||
if take == 0 {
|
||||
// Shorter than the size it is listed with.
|
||||
return Err(StatusCode::Failure);
|
||||
}
|
||||
data.extend_from_slice(&plain[start..start + take]);
|
||||
at += take as u64;
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
async fn open_record(&mut self, index: u64) -> SftpResult<Zeroizing<Vec<u8>>> {
|
||||
self.opener
|
||||
.skip_to(index)
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
let mut record = vec![0u8; self.opener.next_len()];
|
||||
self.file
|
||||
.seek(std::io::SeekFrom::Start(
|
||||
vault::HEADER as u64 + index * vault::RECORD as u64,
|
||||
))
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
self.file
|
||||
.read_exact(&mut record)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
match self.opener.open(&record) {
|
||||
Ok(plain) => Ok(Zeroizing::new(plain)),
|
||||
Err(e) => {
|
||||
tracing::warn!("sftp: could not open a sealed file: {e:?}");
|
||||
Err(StatusCode::Failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Upload {
|
||||
parent: i64,
|
||||
name: String,
|
||||
/// The file node it replaces, when it was opened over one.
|
||||
replaces: Option<i64>,
|
||||
/// Whether it is stored sealed: written into a sealed folder, or over a
|
||||
/// sealed file.
|
||||
sealed: bool,
|
||||
media_id: String,
|
||||
partial: std::path::PathBuf,
|
||||
file: tokio::fs::File,
|
||||
@@ -279,22 +351,40 @@ impl SpaceFs {
|
||||
}
|
||||
}
|
||||
|
||||
/// The key to seal a file written into `parent` with: none when the folder
|
||||
/// is not sealed, and a refusal when it is and the vault is shut.
|
||||
async fn seal_key(&self, parent: i64) -> SftpResult<Option<Arc<vault::Key>>> {
|
||||
/// The key to seal a file written into `parent` with: none when neither
|
||||
/// the folder nor the file it is written over is sealed, and a refusal
|
||||
/// when one of them is and the vault is shut.
|
||||
async fn seal_key(
|
||||
&self,
|
||||
parent: i64,
|
||||
over_sealed: bool,
|
||||
) -> SftpResult<Option<Arc<vault::Key>>> {
|
||||
let sealed: bool = sqlx::query_scalar("SELECT sealed FROM nodes WHERE id = ?1")
|
||||
.bind(parent)
|
||||
.fetch_optional(&self.db)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?
|
||||
.unwrap_or(false);
|
||||
if !sealed {
|
||||
if !sealed && !over_sealed {
|
||||
return Ok(None);
|
||||
}
|
||||
match self.state.as_ref().and_then(|state| state.vault.key()) {
|
||||
Some(key) => Ok(Some(key)),
|
||||
None => Err(StatusCode::PermissionDenied),
|
||||
}
|
||||
self.vault_key().map(Some)
|
||||
}
|
||||
|
||||
/// The vault's key, while it is open; a refusal while it is shut.
|
||||
fn vault_key(&self) -> SftpResult<Arc<vault::Key>> {
|
||||
self.state
|
||||
.as_ref()
|
||||
.and_then(|state| state.vault.key())
|
||||
.ok_or(StatusCode::PermissionDenied)
|
||||
}
|
||||
|
||||
/// Whether the vault is open, asked without holding it open: a mount's
|
||||
/// listings and reads alone do not keep the key in memory.
|
||||
fn vault_open(&self) -> bool {
|
||||
self.state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.vault.is_open())
|
||||
}
|
||||
|
||||
/// Seal what moves into a sealed folder before it moves; see `seal::enter`.
|
||||
@@ -307,7 +397,7 @@ impl SpaceFs {
|
||||
StatusCode::Failure
|
||||
}
|
||||
}),
|
||||
None => self.seal_key(parent).await.map(|_| ()),
|
||||
None => self.seal_key(parent, false).await.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,8 +615,8 @@ impl SpaceFs {
|
||||
),
|
||||
Target::File(file) => {
|
||||
let mut attrs = file_attrs(file.size as u64, file.updated_at);
|
||||
// Listed, but not to be opened here.
|
||||
if file.encrypted {
|
||||
// Listed, but not to be opened while the vault is shut.
|
||||
if file.encrypted && !self.vault_open() {
|
||||
attrs.permissions = Some(0o100000);
|
||||
}
|
||||
attrs
|
||||
@@ -648,17 +738,36 @@ impl SpaceFs {
|
||||
truncate: bool,
|
||||
) -> SftpResult<Upload> {
|
||||
// Refused at the open, not at the close, when the vault is shut.
|
||||
self.seal_key(parent).await?;
|
||||
let over_sealed = replaces.is_some_and(|old| old.encrypted);
|
||||
let key = self.seal_key(parent, over_sealed).await?;
|
||||
let media_id = uuid::Uuid::new_v4().to_string();
|
||||
let partial = self.uploads.join(format!("{media_id}.part"));
|
||||
let mut size = 0;
|
||||
if let (Some(old), false) = (replaces, truncate) {
|
||||
if old.encrypted {
|
||||
return Err(StatusCode::PermissionDenied);
|
||||
}
|
||||
size = tokio::fs::copy(self.uploads.join(&old.media_id), &partial)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
let source = self.uploads.join(&old.media_id);
|
||||
size = match (&key, old.encrypted) {
|
||||
// Written into, not over: the plaintext goes into the
|
||||
// `.part`, which is sealed again when it is closed.
|
||||
(Some(key), true) => {
|
||||
let (key, id, part) = (key.clone(), old.media_id.clone(), partial.clone());
|
||||
tokio::task::spawn_blocking(move || -> anyhow::Result<u64> {
|
||||
let mut plain = vault::Reader::open(&key, &id, &source)?;
|
||||
Ok(std::io::copy(
|
||||
&mut plain,
|
||||
&mut std::fs::File::create(&part)?,
|
||||
)?)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?
|
||||
.map_err(|e| {
|
||||
tracing::warn!("sftp: could not open {name}: {e:?}");
|
||||
StatusCode::Failure
|
||||
})?
|
||||
}
|
||||
_ => tokio::fs::copy(source, &partial)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?,
|
||||
};
|
||||
}
|
||||
let file = tokio::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
@@ -672,6 +781,7 @@ impl SpaceFs {
|
||||
parent,
|
||||
name: name.to_string(),
|
||||
replaces: replaces.map(|old| old.id),
|
||||
sealed: key.is_some(),
|
||||
media_id,
|
||||
partial,
|
||||
file,
|
||||
@@ -687,13 +797,14 @@ impl SpaceFs {
|
||||
parent,
|
||||
name,
|
||||
replaces,
|
||||
sealed,
|
||||
media_id,
|
||||
partial,
|
||||
file,
|
||||
size,
|
||||
} = upload;
|
||||
// Asked again: the vault may have locked while the bytes came in.
|
||||
let key = match self.seal_key(parent).await {
|
||||
let key = match self.seal_key(parent, sealed).await {
|
||||
Ok(key) => key,
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(&partial).await;
|
||||
@@ -953,6 +1064,7 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
let attrs = match self.handles.get(&handle) {
|
||||
Some(OpenHandle::Read(data)) => file_attrs(data.len() as u64, crate::now()),
|
||||
Some(OpenHandle::ReadFile { size, .. }) => file_attrs(*size, crate::now()),
|
||||
Some(OpenHandle::ReadSealed(sealed)) => file_attrs(sealed.size, crate::now()),
|
||||
Some(OpenHandle::Upload(upload)) => file_attrs(upload.size, crate::now()),
|
||||
Some(OpenHandle::Write { buf, .. } | OpenHandle::Scratch { buf, .. }) => {
|
||||
file_attrs(buf.len() as u64, crate::now())
|
||||
@@ -1148,15 +1260,42 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
)
|
||||
} else {
|
||||
let file = existing.ok_or(StatusCode::NoSuchFile)?;
|
||||
if file.encrypted {
|
||||
return Err(StatusCode::PermissionDenied);
|
||||
}
|
||||
let opened = tokio::fs::File::open(self.uploads.join(&file.media_id))
|
||||
let key = if file.encrypted {
|
||||
Some(self.vault_key()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut opened = tokio::fs::File::open(self.uploads.join(&file.media_id))
|
||||
.await
|
||||
.map_err(|_| StatusCode::NoSuchFile)?;
|
||||
OpenHandle::ReadFile {
|
||||
file: opened,
|
||||
size: file.size as u64,
|
||||
match key {
|
||||
Some(key) => {
|
||||
let len = opened
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?
|
||||
.len();
|
||||
let mut header = [0u8; vault::HEADER];
|
||||
opened
|
||||
.read_exact(&mut header)
|
||||
.await
|
||||
.map_err(|_| StatusCode::Failure)?;
|
||||
let opener = vault::Opener::new(&key, &file.media_id, &header, len)
|
||||
.map_err(|e| {
|
||||
tracing::warn!("sftp: could not open {leaf}: {e:?}");
|
||||
StatusCode::Failure
|
||||
})?;
|
||||
OpenHandle::ReadSealed(Box::new(SealedRead {
|
||||
file: opened,
|
||||
opener,
|
||||
size: file.size as u64,
|
||||
record: None,
|
||||
}))
|
||||
}
|
||||
None => OpenHandle::ReadFile {
|
||||
file: opened,
|
||||
size: file.size as u64,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1173,6 +1312,16 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
offset: u64,
|
||||
len: u32,
|
||||
) -> SftpResult<russh_sftp::protocol::Data> {
|
||||
// Locking the vault shuts what is open as well as what opens next.
|
||||
let vault_open = self.vault_open();
|
||||
if let Some(OpenHandle::ReadSealed(sealed)) = self.handles.get_mut(&handle) {
|
||||
if !vault_open {
|
||||
return Err(StatusCode::PermissionDenied);
|
||||
}
|
||||
let data = sealed.read(offset, len).await?;
|
||||
return Ok(russh_sftp::protocol::Data { id, data });
|
||||
}
|
||||
|
||||
// Files are read off the disk, a piece at a time, whether they are
|
||||
// being read or written.
|
||||
let disk = match self.handles.get_mut(&handle) {
|
||||
@@ -1265,7 +1414,12 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
file.mtime = crate::now();
|
||||
}
|
||||
}
|
||||
Some(OpenHandle::Read(_) | OpenHandle::ReadFile { .. } | OpenHandle::Dir(_)) => {}
|
||||
Some(
|
||||
OpenHandle::Read(_)
|
||||
| OpenHandle::ReadFile { .. }
|
||||
| OpenHandle::ReadSealed(_)
|
||||
| OpenHandle::Dir(_),
|
||||
) => {}
|
||||
None => return Err(StatusCode::Failure),
|
||||
}
|
||||
Ok(ok_status(id))
|
||||
@@ -1787,6 +1941,141 @@ mod tests {
|
||||
assert!(matches!(refused, Err(StatusCode::PermissionDenied)));
|
||||
}
|
||||
|
||||
/// A sealed file reads as its plaintext while the vault is open, from any
|
||||
/// offset, is still sealed after it is written into or over — outside a
|
||||
/// sealed folder too — and stops reading the moment the vault locks.
|
||||
#[tokio::test]
|
||||
async fn a_sealed_file_opens_while_the_vault_is_open() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("uploads")).unwrap();
|
||||
let state = crate::routes::tests::state_in(dir.path().into()).await;
|
||||
state.vault.open_with(zeroize::Zeroizing::new([9u8; 32]));
|
||||
let mut fs = SpaceFs::new(state.clone(), None);
|
||||
fs.max_upload = 1024 * 1024;
|
||||
for path in ["/proj", "/proj/vault", "/proj/open"] {
|
||||
fs.mkdir(0, path.into(), FileAttributes::empty())
|
||||
.await
|
||||
.expect("mkdir");
|
||||
}
|
||||
sqlx::query("UPDATE nodes SET sealed = 1 WHERE slug = 'vault'")
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let sealed_files = || async {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM nodes n JOIN media m ON m.id = n.media_id
|
||||
WHERE m.encrypted = 1",
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Three records and a bit, so reads cross from one into the next.
|
||||
let data: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
|
||||
let handle = fs
|
||||
.open(
|
||||
0,
|
||||
"/proj/vault/data.bin".into(),
|
||||
CREATE,
|
||||
FileAttributes::empty(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.handle;
|
||||
fs.write(0, handle.clone(), 0, data.clone()).await.unwrap();
|
||||
fs.close(0, handle).await.unwrap();
|
||||
assert_eq!(sealed_files().await, 1);
|
||||
|
||||
let handle = fs
|
||||
.open(
|
||||
0,
|
||||
"/proj/vault/data.bin".into(),
|
||||
OpenFlags::READ,
|
||||
FileAttributes::empty(),
|
||||
)
|
||||
.await
|
||||
.expect("opens while the vault is open")
|
||||
.handle;
|
||||
let mut whole = Vec::new();
|
||||
while let Ok(piece) = read_at(&mut fs, handle.clone(), whole.len() as u64, 32 * 1024).await
|
||||
{
|
||||
whole.extend(piece);
|
||||
}
|
||||
assert!(whole == data, "reads back as written");
|
||||
let across = read_at(&mut fs, handle.clone(), 65_000, 2_000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(across, data[65_000..67_000]);
|
||||
|
||||
state.vault.lock();
|
||||
assert!(matches!(
|
||||
read_at(&mut fs, handle.clone(), 0, 10).await,
|
||||
Err(StatusCode::PermissionDenied)
|
||||
));
|
||||
fs.close(0, handle).await.unwrap();
|
||||
assert!(matches!(
|
||||
fs.open(
|
||||
0,
|
||||
"/proj/vault/data.bin".into(),
|
||||
OpenFlags::READ,
|
||||
FileAttributes::empty()
|
||||
)
|
||||
.await,
|
||||
Err(StatusCode::PermissionDenied)
|
||||
));
|
||||
state.vault.open_with(zeroize::Zeroizing::new([9u8; 32]));
|
||||
|
||||
// Moved out, it stays sealed, and written into, it is sealed again.
|
||||
fs.rename(
|
||||
0,
|
||||
"/proj/vault/data.bin".into(),
|
||||
"/proj/open/data.bin".into(),
|
||||
)
|
||||
.await
|
||||
.expect("move out");
|
||||
let handle = fs
|
||||
.open(
|
||||
0,
|
||||
"/proj/open/data.bin".into(),
|
||||
OpenFlags::READ | OpenFlags::WRITE,
|
||||
FileAttributes::empty(),
|
||||
)
|
||||
.await
|
||||
.expect("opens for writing")
|
||||
.handle;
|
||||
fs.write(0, handle.clone(), 0, b"XYZ".to_vec())
|
||||
.await
|
||||
.unwrap();
|
||||
fs.close(0, handle).await.unwrap();
|
||||
assert_eq!(sealed_files().await, 1);
|
||||
let mut expected = data.clone();
|
||||
expected[..3].copy_from_slice(b"XYZ");
|
||||
let handle = fs
|
||||
.open(
|
||||
0,
|
||||
"/proj/open/data.bin".into(),
|
||||
OpenFlags::READ,
|
||||
FileAttributes::empty(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.handle;
|
||||
let head = read_at(&mut fs, handle.clone(), 0, 8).await.unwrap();
|
||||
assert_eq!(head, expected[..8]);
|
||||
fs.close(0, handle).await.unwrap();
|
||||
let on_disk: Vec<Vec<u8>> = std::fs::read_dir(dir.path().join("uploads"))
|
||||
.unwrap()
|
||||
.map(|e| std::fs::read(e.unwrap().path()).unwrap())
|
||||
.collect();
|
||||
assert_eq!(on_disk.len(), 1);
|
||||
assert!(on_disk[0].starts_with(crate::vault::MAGIC));
|
||||
|
||||
put(&mut fs, "/proj/open/data.bin", "over").await;
|
||||
assert_eq!(sealed_files().await, 1);
|
||||
assert_eq!(get(&mut fs, "/proj/open/data.bin").await, "over");
|
||||
}
|
||||
|
||||
const CREATE: OpenFlags = OpenFlags::WRITE
|
||||
.union(OpenFlags::CREATE)
|
||||
.union(OpenFlags::TRUNCATE);
|
||||
@@ -1803,6 +2092,15 @@ mod tests {
|
||||
fs.close(0, handle).await.expect("close");
|
||||
}
|
||||
|
||||
async fn read_at(
|
||||
fs: &mut SpaceFs,
|
||||
handle: String,
|
||||
offset: u64,
|
||||
len: u32,
|
||||
) -> Result<Vec<u8>, StatusCode> {
|
||||
fs.read(0, handle, offset, len).await.map(|d| d.data)
|
||||
}
|
||||
|
||||
async fn get(fs: &mut SpaceFs, path: &str) -> String {
|
||||
let handle = fs
|
||||
.open(0, path.into(), OpenFlags::READ, FileAttributes::empty())
|
||||
|
||||
@@ -446,6 +446,14 @@ impl Vault {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the vault is open, without counting as a use that keeps it so:
|
||||
/// for a check that should not hold it open by being asked often.
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.slot()
|
||||
.as_ref()
|
||||
.is_some_and(|open| open.touched.elapsed() < self.idle)
|
||||
}
|
||||
|
||||
pub fn lock(&self) {
|
||||
*self.slot() = None;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user