Files
shell-rs/src/sftp/edit.rs
T
XuzandClaude Opus 5.5 eadbe6481a SFTP 只用 WinSCP 也用的请求:修复经堡垒机上传失败、编辑器保存时连接断开
用户的堡垒机对 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>
2026-10-10 11:51:51 +08:00

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())
);
}
}