用户的堡垒机对 fsetstat 回 Failure,收到 fsync@openssh.com(即使宣称支持)就断开整个会话, 所以上传报「Failure: Failure」(.filepart 已写完整),编辑器保存时 SFTP 连接断开。现在: - 上传关闭文件后才按路径 setstat 设修改时间和权限,不再对打开的句柄 fsetstat; - 不再发 fsync@openssh.com; - 覆盖已有文件也不用 posix-rename@openssh.com,一律先把旧文件改名成备份、发布后再删 (不是原子的)。 测试服务器加了堡垒机模式(bastion_server):对 fsetstat 和所有扩展请求断开会话;新上传、 覆盖上传和编辑器保存都要在同一条连接上完成,并保留修改时间。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
495 lines
16 KiB
Rust
495 lines
16 KiB
Rust
//! Whole text files for the editor: read into memory, written back in place.
|
|
//!
|
|
//! Unlike transfers there is no `.filepart` and no rename: a save truncates
|
|
//! the file and writes it again, as vim, nano and VS Code Remote do, so the
|
|
//! owner, the permissions, links pointing at it and hard links all stay.
|
|
//! What keeps a save from clobbering someone else's change is the stamp: the
|
|
//! size and modification time the file had when it was read or last saved.
|
|
|
|
use super::{EntryKind, FileMetadata, LocalDirectoryProvider, RemotePath, client::RemoteFs};
|
|
use crate::i18n::{t, tn};
|
|
use anyhow::{Result, anyhow, bail};
|
|
use futures::{StreamExt as _, stream::FuturesUnordered};
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
/// The largest file the editor opens. The code editor is meant for up to
|
|
/// about fifty thousand lines.
|
|
pub const EDIT_LIMIT: u64 = 5 * 1024 * 1024;
|
|
|
|
const CHUNK: u32 = 32 * 1024;
|
|
const MAX_IN_FLIGHT: usize = 16;
|
|
/// A NUL byte this early means a binary file, as `grep` and git decide.
|
|
const BINARY_PROBE: usize = 8 * 1024;
|
|
const BOM: &[u8] = b"\xEF\xBB\xBF";
|
|
|
|
/// What a file looked like when it was read or saved: a save checks it is
|
|
/// still so, otherwise someone else changed the file in the meantime.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct FileStamp {
|
|
size: u64,
|
|
modified: Option<SystemTime>,
|
|
}
|
|
|
|
impl FileStamp {
|
|
pub fn new(size: u64, modified: Option<SystemTime>) -> Self {
|
|
Self { size, modified }
|
|
}
|
|
/// A remote file's stamp; SFTP gives whole seconds.
|
|
pub fn of(metadata: &FileMetadata) -> Self {
|
|
Self::new(
|
|
metadata.size(),
|
|
metadata
|
|
.modified()
|
|
.map(|seconds| UNIX_EPOCH + Duration::from_secs(u64::from(seconds))),
|
|
)
|
|
}
|
|
/// A local file's stamp, to the precision the file system keeps.
|
|
pub fn local(metadata: &std::fs::Metadata) -> Self {
|
|
Self::new(metadata.len(), metadata.modified().ok())
|
|
}
|
|
pub fn size(&self) -> u64 {
|
|
self.size
|
|
}
|
|
}
|
|
|
|
/// How a text file is laid out on disk, kept so that a save writes it back
|
|
/// the same way.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub struct TextFormat {
|
|
bom: bool,
|
|
crlf: bool,
|
|
}
|
|
|
|
impl TextFormat {
|
|
pub fn new(bom: bool, crlf: bool) -> Self {
|
|
Self { bom, crlf }
|
|
}
|
|
/// The file starts with a UTF-8 byte order mark.
|
|
pub fn bom(&self) -> bool {
|
|
self.bom
|
|
}
|
|
/// Every line ends in CRLF; the editor sees LF.
|
|
pub fn crlf(&self) -> bool {
|
|
self.crlf
|
|
}
|
|
/// The bytes to write for `text` as the editor holds it.
|
|
pub fn encode(&self, text: &str) -> Vec<u8> {
|
|
let mut bytes = Vec::with_capacity(text.len() + 3);
|
|
if self.bom {
|
|
bytes.extend_from_slice(BOM);
|
|
}
|
|
if self.crlf {
|
|
// A CRLF pasted in stays one line break.
|
|
bytes.extend_from_slice(text.replace("\r\n", "\n").replace('\n', "\r\n").as_bytes());
|
|
} else {
|
|
bytes.extend_from_slice(text.as_bytes());
|
|
}
|
|
bytes
|
|
}
|
|
}
|
|
|
|
/// A file read for editing.
|
|
#[derive(Clone, PartialEq, Eq)]
|
|
pub struct TextFile {
|
|
text: String,
|
|
format: TextFormat,
|
|
stamp: FileStamp,
|
|
}
|
|
|
|
impl TextFile {
|
|
/// UTF-8 text, with or without a byte order mark. A file whose lines all
|
|
/// end in CRLF is shown with LF and written back with CRLF; one with
|
|
/// mixed line ends is kept exactly as it is.
|
|
pub fn decode(mut bytes: Vec<u8>, stamp: FileStamp) -> Result<Self, ReadFailure> {
|
|
if bytes[..bytes.len().min(BINARY_PROBE)].contains(&0) {
|
|
return Err(ReadFailure::NotText);
|
|
}
|
|
let bom = bytes.starts_with(BOM);
|
|
if bom {
|
|
bytes.drain(..BOM.len());
|
|
}
|
|
let text = String::from_utf8(bytes).map_err(|_| ReadFailure::NotText)?;
|
|
let crlf_count = text.matches("\r\n").count();
|
|
let crlf = crlf_count > 0 && crlf_count == text.matches('\n').count();
|
|
let text = if crlf {
|
|
text.replace("\r\n", "\n")
|
|
} else {
|
|
text
|
|
};
|
|
Ok(Self {
|
|
text,
|
|
format: TextFormat { bom, crlf },
|
|
stamp,
|
|
})
|
|
}
|
|
pub fn text(&self) -> &str {
|
|
&self.text
|
|
}
|
|
pub fn format(&self) -> TextFormat {
|
|
self.format
|
|
}
|
|
pub fn stamp(&self) -> FileStamp {
|
|
self.stamp
|
|
}
|
|
pub fn into_parts(self) -> (String, TextFormat, FileStamp) {
|
|
(self.text, self.format, self.stamp)
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for TextFile {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("TextFile")
|
|
.field("bytes", &self.text.len())
|
|
.field("format", &self.format)
|
|
.field("stamp", &self.stamp)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Why a file was not opened for editing.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum ReadFailure {
|
|
/// A directory, a device or the like.
|
|
NotFile,
|
|
/// Larger than `EDIT_LIMIT`; the size in bytes.
|
|
TooLarge(u64),
|
|
/// Binary, or text in an encoding other than UTF-8.
|
|
NotText,
|
|
Failed(String),
|
|
}
|
|
|
|
impl std::fmt::Display for ReadFailure {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
ReadFailure::NotFile => f.write_str(&t!("sftp.edit.not_file")),
|
|
ReadFailure::TooLarge(size) => f.write_str(&tn!("sftp.edit.too_large", *size)),
|
|
ReadFailure::NotText => f.write_str(&t!("sftp.edit.not_text")),
|
|
ReadFailure::Failed(message) => f.write_str(message),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ReadFailure {}
|
|
|
|
impl ReadFailure {
|
|
/// The refusal inside `error`, or the error itself as a failure.
|
|
pub(crate) fn from_error(error: anyhow::Error) -> Self {
|
|
match error.downcast::<ReadFailure>() {
|
|
Ok(refusal) => refusal,
|
|
Err(error) => ReadFailure::Failed(format!("{error:#}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Why a save did not happen, or did not finish.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum SaveFailure {
|
|
/// The file is no longer what was read or last saved: changed, or gone.
|
|
/// Nothing was written.
|
|
Changed,
|
|
/// The file was truncated and then the write failed: what is on disk
|
|
/// now is incomplete, and no longer what was read.
|
|
Interrupted(String),
|
|
/// Nothing was written.
|
|
Failed(String),
|
|
}
|
|
|
|
impl std::fmt::Display for SaveFailure {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
SaveFailure::Changed => f.write_str(&t!("sftp.edit.changed")),
|
|
SaveFailure::Interrupted(message) | SaveFailure::Failed(message) => {
|
|
f.write_str(message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for SaveFailure {}
|
|
|
|
impl SaveFailure {
|
|
pub(crate) fn from_error(error: anyhow::Error) -> Self {
|
|
match error.downcast::<SaveFailure>() {
|
|
Ok(refusal) => refusal,
|
|
Err(error) => SaveFailure::Failed(format!("{error:#}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A whole file read as it is, for a preview: an image, say.
|
|
#[derive(Clone, PartialEq, Eq)]
|
|
pub struct FileBytes(Vec<u8>);
|
|
|
|
impl FileBytes {
|
|
pub fn new(bytes: Vec<u8>) -> Self {
|
|
Self(bytes)
|
|
}
|
|
pub fn len(&self) -> usize {
|
|
self.0.len()
|
|
}
|
|
pub fn is_empty(&self) -> bool {
|
|
self.0.is_empty()
|
|
}
|
|
pub fn into_inner(self) -> Vec<u8> {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for FileBytes {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "FileBytes({} bytes)", self.0.len())
|
|
}
|
|
}
|
|
|
|
/// Read a remote text file, following links. Refusals come back as a
|
|
/// `ReadFailure` inside the error, so that the worker can still tell a
|
|
/// broken connection from the rest.
|
|
pub(crate) async fn read_text<F: RemoteFs>(fs: &F, path: &RemotePath) -> Result<TextFile> {
|
|
let (bytes, stamp) = read_bytes(fs, path, EDIT_LIMIT, true).await?;
|
|
Ok(TextFile::decode(bytes, stamp)?)
|
|
}
|
|
|
|
/// Read a remote file whole, following links, binary or not; refused like
|
|
/// `read_text` when it is not a file or larger than `limit`.
|
|
pub(crate) async fn read_whole<F: RemoteFs>(
|
|
fs: &F,
|
|
path: &RemotePath,
|
|
limit: u64,
|
|
) -> Result<FileBytes> {
|
|
let (bytes, _) = read_bytes(fs, path, limit, false).await?;
|
|
Ok(FileBytes(bytes))
|
|
}
|
|
|
|
/// `stat`, refuse what is not a file or too large, then read it all. For
|
|
/// `text`, a binary file is given up on after its first chunk.
|
|
async fn read_bytes<F: RemoteFs>(
|
|
fs: &F,
|
|
path: &RemotePath,
|
|
limit: u64,
|
|
text: bool,
|
|
) -> Result<(Vec<u8>, FileStamp)> {
|
|
let Some(metadata) = fs.stat(path).await? else {
|
|
bail!(t!("sftp.edit.missing"));
|
|
};
|
|
if metadata.kind() != EntryKind::File {
|
|
return Err(ReadFailure::NotFile.into());
|
|
}
|
|
if metadata.size() > limit {
|
|
return Err(ReadFailure::TooLarge(metadata.size()).into());
|
|
}
|
|
let handle = fs.open_read(path).await?;
|
|
let bytes = read_all(fs, &handle, metadata.size(), text).await;
|
|
let close = fs.close(&handle).await;
|
|
let bytes = bytes?;
|
|
close?;
|
|
Ok((bytes, FileStamp::of(&metadata)))
|
|
}
|
|
|
|
/// Read a local text file through `provider`. Blocking, like the provider.
|
|
pub fn read_local_text(
|
|
provider: &dyn LocalDirectoryProvider,
|
|
path: &std::path::Path,
|
|
) -> Result<TextFile, ReadFailure> {
|
|
let (bytes, stamp) = provider
|
|
.read_file(path, EDIT_LIMIT)
|
|
.map_err(ReadFailure::from_error)?;
|
|
TextFile::decode(bytes, stamp)
|
|
}
|
|
|
|
/// Read a local file whole through `provider`, binary or not. Blocking.
|
|
pub fn read_local_bytes(
|
|
provider: &dyn LocalDirectoryProvider,
|
|
path: &std::path::Path,
|
|
limit: u64,
|
|
) -> Result<FileBytes, ReadFailure> {
|
|
let (bytes, _) = provider
|
|
.read_file(path, limit)
|
|
.map_err(ReadFailure::from_error)?;
|
|
Ok(FileBytes(bytes))
|
|
}
|
|
|
|
/// Write the editor's bytes over a local file through `provider`. Blocking.
|
|
pub fn write_local_text(
|
|
provider: &dyn LocalDirectoryProvider,
|
|
path: &std::path::Path,
|
|
bytes: &[u8],
|
|
expected: Option<FileStamp>,
|
|
) -> Result<FileStamp, SaveFailure> {
|
|
provider
|
|
.write_file(path, bytes, expected)
|
|
.map_err(SaveFailure::from_error)
|
|
}
|
|
|
|
/// `size` bytes from the start of an open file, reading ahead in parallel.
|
|
/// Reads may come back out of order or short; each lands at its offset.
|
|
/// For `text`, a NUL early on stops it: a binary file.
|
|
async fn read_all<F: RemoteFs>(fs: &F, handle: &str, size: u64, text: bool) -> Result<Vec<u8>> {
|
|
let mut buffer = vec![0; usize::try_from(size)?];
|
|
let mut next = 0;
|
|
let mut remainders: Vec<(u64, u32)> = Vec::new();
|
|
let mut reads = FuturesUnordered::new();
|
|
loop {
|
|
while reads.len() < MAX_IN_FLIGHT && (!remainders.is_empty() || next < size) {
|
|
let (at, len) = remainders.pop().unwrap_or_else(|| {
|
|
let len = (size - next).min(u64::from(CHUNK)) as u32;
|
|
let request = (next, len);
|
|
next += u64::from(len);
|
|
request
|
|
});
|
|
reads.push(async move { (at, len, fs.read(handle, at, len).await) });
|
|
}
|
|
let Some((at, len, bytes)) = reads.next().await else {
|
|
return Ok(buffer);
|
|
};
|
|
let bytes = match bytes? {
|
|
Some(bytes) if !bytes.is_empty() => bytes,
|
|
_ => bail!(t!("sftp.edit.shrank")),
|
|
};
|
|
let got = bytes.len().min(len as usize);
|
|
let start = at as usize;
|
|
buffer[start..start + got].copy_from_slice(&bytes[..got]);
|
|
// Give up on a binary file before reading all of it.
|
|
if text && at == 0 && bytes[..got.min(BINARY_PROBE)].contains(&0) {
|
|
return Err(ReadFailure::NotText.into());
|
|
}
|
|
if got < len as usize {
|
|
remainders.push((at + got as u64, len - got as u32));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Write `bytes` over a remote file in place, following links and creating
|
|
/// it when it is gone. With `expected`, the file must still match it, or
|
|
/// the save fails with `SaveFailure::Changed` and nothing is written.
|
|
pub(crate) async fn write_in_place<F: RemoteFs>(
|
|
fs: &F,
|
|
path: &RemotePath,
|
|
bytes: Vec<u8>,
|
|
expected: Option<FileStamp>,
|
|
) -> Result<FileStamp> {
|
|
if let Some(expected) = expected {
|
|
let now = fs.stat(path).await?;
|
|
if now.as_ref().map(FileStamp::of) != Some(expected) {
|
|
return Err(SaveFailure::Changed.into());
|
|
}
|
|
}
|
|
let handle = fs.open_replace(path).await?;
|
|
// From here on the file is truncated: a failure leaves it incomplete.
|
|
// The context keeps the cause, so a dropped connection is still told
|
|
// apart.
|
|
let interrupted = |error: anyhow::Error| {
|
|
let message = format!("{error:#}");
|
|
error.context(SaveFailure::Interrupted(message))
|
|
};
|
|
let written = write_all(fs, &handle, bytes).await;
|
|
let close = fs.close(&handle).await;
|
|
written.map_err(interrupted)?;
|
|
close.map_err(interrupted)?;
|
|
let metadata = fs
|
|
.stat(path)
|
|
.await?
|
|
.ok_or_else(|| anyhow!(t!("sftp.edit.gone_after_save")))?;
|
|
Ok(FileStamp::of(&metadata))
|
|
}
|
|
|
|
async fn write_all<F: RemoteFs>(fs: &F, handle: &str, bytes: Vec<u8>) -> Result<()> {
|
|
let mut chunks = bytes
|
|
.chunks(CHUNK as usize)
|
|
.enumerate()
|
|
.map(|(index, chunk)| (index as u64 * u64::from(CHUNK), chunk.to_vec()));
|
|
let mut writes = FuturesUnordered::new();
|
|
loop {
|
|
while writes.len() < MAX_IN_FLIGHT {
|
|
let Some((offset, chunk)) = chunks.next() else {
|
|
break;
|
|
};
|
|
writes.push(fs.write(handle, offset, chunk));
|
|
}
|
|
match writes.next().await {
|
|
Some(result) => result?,
|
|
None => return Ok(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn stamp() -> FileStamp {
|
|
FileStamp::new(0, None)
|
|
}
|
|
|
|
#[test]
|
|
fn plain_utf8_is_kept_as_it_is() {
|
|
let file = TextFile::decode("a\nb\n中文".into(), stamp()).unwrap();
|
|
assert_eq!(file.text(), "a\nb\n中文");
|
|
assert_eq!(file.format(), TextFormat::default());
|
|
assert_eq!(file.format().encode(file.text()), "a\nb\n中文".as_bytes());
|
|
}
|
|
|
|
#[test]
|
|
fn crlf_lines_are_edited_as_lf_and_written_back_as_crlf() {
|
|
let file = TextFile::decode(b"a\r\nb\r\n".to_vec(), stamp()).unwrap();
|
|
assert_eq!(file.text(), "a\nb\n");
|
|
assert!(file.format().crlf());
|
|
assert_eq!(file.format().encode("a\nb\nc\n"), b"a\r\nb\r\nc\r\n");
|
|
// A CRLF pasted in does not become CR CR LF.
|
|
assert_eq!(file.format().encode("a\r\nb"), b"a\r\nb");
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_line_ends_are_left_alone() {
|
|
let file = TextFile::decode(b"a\r\nb\nc".to_vec(), stamp()).unwrap();
|
|
assert_eq!(file.text(), "a\r\nb\nc");
|
|
assert!(!file.format().crlf());
|
|
assert_eq!(file.format().encode(file.text()), b"a\r\nb\nc");
|
|
}
|
|
|
|
#[test]
|
|
fn a_byte_order_mark_is_hidden_and_written_back() {
|
|
let file = TextFile::decode(b"\xEF\xBB\xBFkey=1\r\n".to_vec(), stamp()).unwrap();
|
|
assert_eq!(file.text(), "key=1\n");
|
|
assert_eq!(file.format(), TextFormat::new(true, true));
|
|
assert_eq!(file.format().encode("key=2\n"), b"\xEF\xBB\xBFkey=2\r\n");
|
|
}
|
|
|
|
#[test]
|
|
fn binary_and_other_encodings_are_not_text() {
|
|
assert_eq!(
|
|
TextFile::decode(b"PK\x03\x04\x00\x00".to_vec(), stamp()),
|
|
Err(ReadFailure::NotText)
|
|
);
|
|
// 「中文」 in GBK.
|
|
assert_eq!(
|
|
TextFile::decode(b"\xD6\xD0\xCE\xC4".to_vec(), stamp()),
|
|
Err(ReadFailure::NotText)
|
|
);
|
|
assert_eq!(TextFile::decode(Vec::new(), stamp()).unwrap().text(), "");
|
|
}
|
|
|
|
#[test]
|
|
fn refusals_survive_the_trip_through_anyhow() {
|
|
let error: anyhow::Error = ReadFailure::TooLarge(9).into();
|
|
assert_eq!(ReadFailure::from_error(error), ReadFailure::TooLarge(9));
|
|
assert_eq!(
|
|
ReadFailure::from_error(anyhow!("连接已断开")),
|
|
ReadFailure::Failed("连接已断开".into())
|
|
);
|
|
let error: anyhow::Error = SaveFailure::Changed.into();
|
|
assert_eq!(SaveFailure::from_error(error), SaveFailure::Changed);
|
|
// Interrupted rides as context over the cause, which stays findable.
|
|
let cause = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
|
|
let error = anyhow::Error::from(cause).context(SaveFailure::Interrupted("reset".into()));
|
|
assert!(
|
|
error
|
|
.chain()
|
|
.any(|c| c.downcast_ref::<std::io::Error>().is_some())
|
|
);
|
|
assert_eq!(
|
|
SaveFailure::from_error(error),
|
|
SaveFailure::Interrupted("reset".into())
|
|
);
|
|
}
|
|
}
|