diff --git a/README.md b/README.md
index 51e7ca1..e857ddd 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/src/sftp/fs.rs b/src/sftp/fs.rs
index 06e0d23..42bb5f4 100644
--- a/src/sftp/fs.rs
+++ b/src/sftp/fs.rs
@@ -8,10 +8,12 @@
//! A document reads as `#
` 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, 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),
/// A file being written to `uploads/.part`, stored on close.
Upload(Upload),
/// A scratch file being written, stored back under `path` on close.
Scratch { path: String, buf: Vec },
}
+/// 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>)>,
+}
+
+impl SealedRead {
+ async fn read(&mut self, offset: u64, len: u32) -> SftpResult> {
+ 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>> {
+ 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,
+ /// 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