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
|
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
|
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
|
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
|
disk under the same size limit. While the vault is open in the browser a
|
||||||
there. A file copied into a sealed folder is sealed while the vault is open in
|
sealed file opens there too, decrypted as it is read, and is sealed again when
|
||||||
the browser, and refused while it is shut. Editor droppings — swap files, `~` backups, dotted temp files — stay in
|
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
|
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.
|
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
|
//! 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.
|
//! 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
|
//! 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
|
//! `.part`, never held in memory. A sealed file opens here while the vault is
|
||||||
//! opened here: the vault is opened in the browser, for the browser. A file
|
//! open in the browser, decrypted a record at a time as it is read, and is
|
||||||
//! written into a sealed folder, or moved into one, is sealed while the vault
|
//! sealed again when it is written over; while the vault is shut it is listed
|
||||||
//! is open there and refused while it is shut.
|
//! 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,
|
//! 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
|
//! 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 sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
backup::{
|
backup::{
|
||||||
@@ -212,17 +215,86 @@ enum OpenHandle {
|
|||||||
Write { id: i64, buf: Vec<u8>, base: i64 },
|
Write { id: i64, buf: Vec<u8>, base: i64 },
|
||||||
/// A file node's bytes, read from disk as asked for.
|
/// A file node's bytes, read from disk as asked for.
|
||||||
ReadFile { file: tokio::fs::File, size: u64 },
|
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.
|
/// A file being written to `uploads/<id>.part`, stored on close.
|
||||||
Upload(Upload),
|
Upload(Upload),
|
||||||
/// A scratch file being written, stored back under `path` on close.
|
/// A scratch file being written, stored back under `path` on close.
|
||||||
Scratch { path: String, buf: Vec<u8> },
|
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 {
|
struct Upload {
|
||||||
parent: i64,
|
parent: i64,
|
||||||
name: String,
|
name: String,
|
||||||
/// The file node it replaces, when it was opened over one.
|
/// The file node it replaces, when it was opened over one.
|
||||||
replaces: Option<i64>,
|
replaces: Option<i64>,
|
||||||
|
/// Whether it is stored sealed: written into a sealed folder, or over a
|
||||||
|
/// sealed file.
|
||||||
|
sealed: bool,
|
||||||
media_id: String,
|
media_id: String,
|
||||||
partial: std::path::PathBuf,
|
partial: std::path::PathBuf,
|
||||||
file: tokio::fs::File,
|
file: tokio::fs::File,
|
||||||
@@ -279,22 +351,40 @@ impl SpaceFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The key to seal a file written into `parent` with: none when the folder
|
/// The key to seal a file written into `parent` with: none when neither
|
||||||
/// is not sealed, and a refusal when it is and the vault is shut.
|
/// the folder nor the file it is written over is sealed, and a refusal
|
||||||
async fn seal_key(&self, parent: i64) -> SftpResult<Option<Arc<vault::Key>>> {
|
/// 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")
|
let sealed: bool = sqlx::query_scalar("SELECT sealed FROM nodes WHERE id = ?1")
|
||||||
.bind(parent)
|
.bind(parent)
|
||||||
.fetch_optional(&self.db)
|
.fetch_optional(&self.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::Failure)?
|
.map_err(|_| StatusCode::Failure)?
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !sealed {
|
if !sealed && !over_sealed {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
match self.state.as_ref().and_then(|state| state.vault.key()) {
|
self.vault_key().map(Some)
|
||||||
Some(key) => Ok(Some(key)),
|
}
|
||||||
None => Err(StatusCode::PermissionDenied),
|
|
||||||
}
|
/// 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`.
|
/// Seal what moves into a sealed folder before it moves; see `seal::enter`.
|
||||||
@@ -307,7 +397,7 @@ impl SpaceFs {
|
|||||||
StatusCode::Failure
|
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) => {
|
Target::File(file) => {
|
||||||
let mut attrs = file_attrs(file.size as u64, file.updated_at);
|
let mut attrs = file_attrs(file.size as u64, file.updated_at);
|
||||||
// Listed, but not to be opened here.
|
// Listed, but not to be opened while the vault is shut.
|
||||||
if file.encrypted {
|
if file.encrypted && !self.vault_open() {
|
||||||
attrs.permissions = Some(0o100000);
|
attrs.permissions = Some(0o100000);
|
||||||
}
|
}
|
||||||
attrs
|
attrs
|
||||||
@@ -648,17 +738,36 @@ impl SpaceFs {
|
|||||||
truncate: bool,
|
truncate: bool,
|
||||||
) -> SftpResult<Upload> {
|
) -> SftpResult<Upload> {
|
||||||
// Refused at the open, not at the close, when the vault is shut.
|
// 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 media_id = uuid::Uuid::new_v4().to_string();
|
||||||
let partial = self.uploads.join(format!("{media_id}.part"));
|
let partial = self.uploads.join(format!("{media_id}.part"));
|
||||||
let mut size = 0;
|
let mut size = 0;
|
||||||
if let (Some(old), false) = (replaces, truncate) {
|
if let (Some(old), false) = (replaces, truncate) {
|
||||||
if old.encrypted {
|
let source = self.uploads.join(&old.media_id);
|
||||||
return Err(StatusCode::PermissionDenied);
|
size = match (&key, old.encrypted) {
|
||||||
}
|
// Written into, not over: the plaintext goes into the
|
||||||
size = tokio::fs::copy(self.uploads.join(&old.media_id), &partial)
|
// `.part`, which is sealed again when it is closed.
|
||||||
.await
|
(Some(key), true) => {
|
||||||
.map_err(|_| StatusCode::Failure)?;
|
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()
|
let file = tokio::fs::OpenOptions::new()
|
||||||
.read(true)
|
.read(true)
|
||||||
@@ -672,6 +781,7 @@ impl SpaceFs {
|
|||||||
parent,
|
parent,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
replaces: replaces.map(|old| old.id),
|
replaces: replaces.map(|old| old.id),
|
||||||
|
sealed: key.is_some(),
|
||||||
media_id,
|
media_id,
|
||||||
partial,
|
partial,
|
||||||
file,
|
file,
|
||||||
@@ -687,13 +797,14 @@ impl SpaceFs {
|
|||||||
parent,
|
parent,
|
||||||
name,
|
name,
|
||||||
replaces,
|
replaces,
|
||||||
|
sealed,
|
||||||
media_id,
|
media_id,
|
||||||
partial,
|
partial,
|
||||||
file,
|
file,
|
||||||
size,
|
size,
|
||||||
} = upload;
|
} = upload;
|
||||||
// Asked again: the vault may have locked while the bytes came in.
|
// 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,
|
Ok(key) => key,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tokio::fs::remove_file(&partial).await;
|
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) {
|
let attrs = match self.handles.get(&handle) {
|
||||||
Some(OpenHandle::Read(data)) => file_attrs(data.len() as u64, crate::now()),
|
Some(OpenHandle::Read(data)) => file_attrs(data.len() as u64, crate::now()),
|
||||||
Some(OpenHandle::ReadFile { size, .. }) => file_attrs(*size, 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::Upload(upload)) => file_attrs(upload.size, crate::now()),
|
||||||
Some(OpenHandle::Write { buf, .. } | OpenHandle::Scratch { buf, .. }) => {
|
Some(OpenHandle::Write { buf, .. } | OpenHandle::Scratch { buf, .. }) => {
|
||||||
file_attrs(buf.len() as u64, crate::now())
|
file_attrs(buf.len() as u64, crate::now())
|
||||||
@@ -1148,15 +1260,42 @@ impl russh_sftp::server::Handler for SpaceFs {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let file = existing.ok_or(StatusCode::NoSuchFile)?;
|
let file = existing.ok_or(StatusCode::NoSuchFile)?;
|
||||||
if file.encrypted {
|
let key = if file.encrypted {
|
||||||
return Err(StatusCode::PermissionDenied);
|
Some(self.vault_key()?)
|
||||||
}
|
} else {
|
||||||
let opened = tokio::fs::File::open(self.uploads.join(&file.media_id))
|
None
|
||||||
|
};
|
||||||
|
let mut opened = tokio::fs::File::open(self.uploads.join(&file.media_id))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::NoSuchFile)?;
|
.map_err(|_| StatusCode::NoSuchFile)?;
|
||||||
OpenHandle::ReadFile {
|
match key {
|
||||||
file: opened,
|
Some(key) => {
|
||||||
size: file.size as u64,
|
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,
|
offset: u64,
|
||||||
len: u32,
|
len: u32,
|
||||||
) -> SftpResult<russh_sftp::protocol::Data> {
|
) -> 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
|
// Files are read off the disk, a piece at a time, whether they are
|
||||||
// being read or written.
|
// being read or written.
|
||||||
let disk = match self.handles.get_mut(&handle) {
|
let disk = match self.handles.get_mut(&handle) {
|
||||||
@@ -1265,7 +1414,12 @@ impl russh_sftp::server::Handler for SpaceFs {
|
|||||||
file.mtime = crate::now();
|
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),
|
None => return Err(StatusCode::Failure),
|
||||||
}
|
}
|
||||||
Ok(ok_status(id))
|
Ok(ok_status(id))
|
||||||
@@ -1787,6 +1941,141 @@ mod tests {
|
|||||||
assert!(matches!(refused, Err(StatusCode::PermissionDenied)));
|
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
|
const CREATE: OpenFlags = OpenFlags::WRITE
|
||||||
.union(OpenFlags::CREATE)
|
.union(OpenFlags::CREATE)
|
||||||
.union(OpenFlags::TRUNCATE);
|
.union(OpenFlags::TRUNCATE);
|
||||||
@@ -1803,6 +2092,15 @@ mod tests {
|
|||||||
fs.close(0, handle).await.expect("close");
|
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 {
|
async fn get(fs: &mut SpaceFs, path: &str) -> String {
|
||||||
let handle = fs
|
let handle = fs
|
||||||
.open(0, path.into(), OpenFlags::READ, FileAttributes::empty())
|
.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) {
|
pub fn lock(&self) {
|
||||||
*self.slot() = None;
|
*self.slot() = None;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user