Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be3f56b0c | ||
|
|
eadbe6481a | ||
|
|
ecf9d92e47 |
No files matched your search
@@ -2,6 +2,12 @@
|
||||
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
|
||||
|
||||
## [0.1.7-beta.1]
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复经部分堡垒机上传文件失败、编辑器保存时 SFTP 连接断开。
|
||||
|
||||
## [0.1.6]
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -280,6 +280,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **每个标签独立连接、独立传输引擎。** 引擎一次只跑一批,其余在 `TransferQueue` 里排队,引擎的 `Idle` 推动队列前进;进度只归队首;停止的批次挡在队首,等用户继续或移出。
|
||||
- **SFTP 独立于终端。** 关闭终端不停传输;断开或删除主机会停止传输,并保留续传数据。
|
||||
- **下载严格按偏移顺序写 `.filepart`**,因为续传只信它的长度。
|
||||
- **只发 WinSCP 也发的请求**:用户的堡垒机对 `fsetstat` 回 Failure,收到 `fsync@openssh.com`(即使宣称支持)就断开整个会话。所以上传关闭文件后才按路径 `setstat` 设时间和权限;不用任何扩展:不发 fsync,覆盖也不用 `posix-rename@openssh.com`,而是先把旧文件改名成备份、发布后再删(不是原子的)。`protocol_tests` 的 `bastion_server` 对 `fsetstat` 和所有扩展请求都断开会话,守着这条。
|
||||
- **文件操作从不跟随符号链接**:删除只删链接本身,改权限跳过链接,传输原样创建链接。
|
||||
- **空闲时也要发现断网**:通道流包一层 `WatchedStream`。等待时只持有 `Weak`,不能攥着 `Arc<SftpClient>`,否则「断开」关不掉连接。
|
||||
- **选择归面板,不归表格。** `DataTable` 设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`。「名称」单元格才是项目,拖文件只从图标和文件名开始,其余地方按下拖动是框选。
|
||||
|
||||
Generated
+1
-1
@@ -7738,7 +7738,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7-beta.1"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7-beta.1"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH client and host manager: terminal, SFTP, port forwarding"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<a href="#license"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
|
||||
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
|
||||
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><b>English</b> | <a href="README.zh-CN.md">简体中文</a></p>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<a href="#许可证"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
|
||||
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
|
||||
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="README.md">English</a> | <b>简体中文</b></p>
|
||||
|
||||
+1
-1
@@ -385,7 +385,7 @@ Works like WinSCP’s queue panel: it appears below the two panes when there are
|
||||
|
||||
## Resuming and replacing
|
||||
|
||||
Files are first written to `<destination file name>.filepart`. Write requests go through a bounded pipeline, and the real file is published only after every write has been acknowledged and the file has closed successfully; when the server supports it, `fsync@openssh.com` is called first. Servers that support `posix-rename@openssh.com` replace the file atomically; on other servers, the old file is first moved to `.shellrs-<UUID>.backup` and then the new file is published, a compatibility path that isn’t guaranteed to be atomic. If the reply to a publish is lost, ShellRS recovers using the managed temporary path, the stage, the type and the size; when it can’t tell safely, it keeps the recovery files and reports it.
|
||||
Files are first written to `<destination file name>.filepart`. Write requests go through a bounded pipeline, and once every write has been acknowledged and the file has closed successfully, its modification time (and, over an existing file, that file’s permissions) is set by path before the real file is published. Over an existing file, the old file is first moved to `.shellrs-<UUID>.backup`, then the new file is published and the backup deleted; this replacement isn’t atomic, as the destination is briefly missing between the two renames. Like WinSCP, transfers use no SFTP extensions (`fsync@openssh.com`, `posix-rename@openssh.com` and so on) and set no attributes on an open file: some bastion hosts refuse these requests, or even end the whole session over them. If the reply to a publish is lost, ShellRS recovers using the managed temporary path, the stage, the type and the size; when it can’t tell safely, it keeps the recovery files and reports it.
|
||||
|
||||
Resume records are kept in `upload-resume/` in the app’s data folder, as versioned JSON written atomically; they never contain passwords, passphrases or private keys, and they don’t change the database schema. A record ties together the endpoint, host fingerprint, source, destination and safe-replace stage. Resuming works as in WinSCP: ShellRS reads the current size of the remote `.filepart`, skips that much of the local file and carries on; it’s up to you to make sure the source is still the same version. Uploads never read the remote file back to verify it.
|
||||
|
||||
|
||||
+1
-1
@@ -391,7 +391,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
|
||||
|
||||
## 断点续传和替换
|
||||
|
||||
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后才发布正式文件;服务器支持时先调用 `fsync@openssh.com`。支持 `posix-rename@openssh.com` 的服务器使用原子替换;其他服务器先将旧文件移到 `.shellrs-<UUID>.backup`,再发布新文件,此兼容路径不保证原子性。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
|
||||
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后,按路径设置修改时间(覆盖时还有原文件的权限),再发布正式文件。覆盖已有文件时,先将旧文件移到 `.shellrs-<UUID>.backup`,再发布新文件,最后删除备份;这样替换不是原子的,两次改名之间目标短暂不存在。和 WinSCP 一样,传输不用任何 SFTP 扩展(`fsync@openssh.com`、`posix-rename@openssh.com` 等),也不对打开的文件设置属性:有的堡垒机拒绝这些请求,甚至因此断开整个会话。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
|
||||
|
||||
续传记录保存在应用数据目录的 `upload-resume/`,采用版本化 JSON 和原子写入,不写入密码、口令或私钥,不改变数据库结构。记录用于绑定端点、主机指纹、来源、目标和安全替换阶段。续传方式与 WinSCP 相同:读取远端 `.filepart` 的当前大小,在本地文件中跳过相同长度并继续传输;使用者需确保来源仍是同一版本。上传过程不会为了校验而回读远端文件内容。
|
||||
|
||||
|
||||
@@ -305,9 +305,6 @@ sftp.client.no_path:
|
||||
sftp.client.no_link_target:
|
||||
zh-CN: 无法读取链接目标
|
||||
en: Couldn’t read the link target
|
||||
sftp.client.bad_rename_reply:
|
||||
zh-CN: 服务器返回了无效的重命名响应
|
||||
en: The server sent an invalid reply to the rename
|
||||
sftp.client.ambiguous_name:
|
||||
zh-CN: 远端名称包含无法可靠表示的字符,已停止操作
|
||||
en: A remote name has characters that can’t be shown reliably, so the operation stopped
|
||||
|
||||
+17
-48
@@ -6,7 +6,7 @@ use anyhow::{Result, anyhow, bail};
|
||||
use futures::StreamExt as _;
|
||||
use russh_sftp::{
|
||||
client::{RawSftpSession, error::Error},
|
||||
protocol::{FileAttributes, OpenFlags, Packet, StatusCode},
|
||||
protocol::{FileAttributes, OpenFlags, StatusCode},
|
||||
};
|
||||
use std::{
|
||||
pin::Pin,
|
||||
@@ -34,25 +34,29 @@ pub(crate) trait RemoteFs {
|
||||
async fn open_read(&self, path: &RemotePath) -> Result<String>;
|
||||
/// Up to `len` bytes at `offset`; may return fewer. `None` at end of file.
|
||||
async fn read(&self, handle: &str, offset: u64, len: u32) -> Result<Option<Vec<u8>>>;
|
||||
async fn sync(&self, handle: &str) -> Result<()>;
|
||||
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()>;
|
||||
async fn close(&self, handle: &str) -> Result<()>;
|
||||
/// Set the modification time and permission bits of a closed file by its
|
||||
/// path (`setstat`), as WinSCP does: a bastion host has been seen to
|
||||
/// refuse `fsetstat` on a file still open.
|
||||
async fn attributes(
|
||||
&self,
|
||||
handle: &str,
|
||||
path: &RemotePath,
|
||||
modified: Option<u32>,
|
||||
permissions: Option<u32>,
|
||||
) -> Result<()>;
|
||||
async fn mkdir(&self, path: &RemotePath) -> Result<()>;
|
||||
async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()>;
|
||||
async fn readlink(&self, path: &RemotePath) -> Result<String>;
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()>;
|
||||
/// A plain `rename`, which fails when `to` is there: replacing a file
|
||||
/// moves the old one aside first, as WinSCP never sends
|
||||
/// `posix-rename@openssh.com` either.
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()>;
|
||||
async fn remove(&self, path: &RemotePath) -> Result<()>;
|
||||
/// Remove an empty directory.
|
||||
async fn rmdir(&self, path: &RemotePath) -> Result<()>;
|
||||
/// Set the permission bits of a path (`setstat`, which follows links).
|
||||
async fn set_permissions(&self, path: &RemotePath, permissions: u32) -> Result<()>;
|
||||
fn atomic_replace(&self) -> bool;
|
||||
}
|
||||
|
||||
pub(crate) struct SftpClient {
|
||||
@@ -61,8 +65,6 @@ pub(crate) struct SftpClient {
|
||||
/// Turns true once the server side of the SFTP stream has ended.
|
||||
closed: watch::Receiver<bool>,
|
||||
fingerprint: String,
|
||||
atomic_replace: bool,
|
||||
fsync: bool,
|
||||
}
|
||||
impl SftpClient {
|
||||
pub async fn connect(
|
||||
@@ -92,21 +94,14 @@ impl SftpClient {
|
||||
if version.version != 3 {
|
||||
bail!(t!("sftp.client.no_v3"));
|
||||
}
|
||||
let atomic_replace = version
|
||||
.extensions
|
||||
.get("posix-rename@openssh.com")
|
||||
.is_some_and(|v| v == "1");
|
||||
let fsync = version
|
||||
.extensions
|
||||
.get("fsync@openssh.com")
|
||||
.is_some_and(|v| v == "1");
|
||||
// No extension is used, even when offered: a bastion host that offers
|
||||
// `fsync@openssh.com` has been seen to end the whole session on it.
|
||||
// WinSCP sends none of them for a transfer.
|
||||
Ok(Self {
|
||||
raw,
|
||||
ssh,
|
||||
closed,
|
||||
fingerprint,
|
||||
atomic_replace,
|
||||
fsync,
|
||||
})
|
||||
}
|
||||
/// Only the Unix tests have a local `sftp-server` to talk to.
|
||||
@@ -281,12 +276,6 @@ impl RemoteFs for SftpClient {
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
async fn sync(&self, handle: &str) -> Result<()> {
|
||||
if self.fsync {
|
||||
self.raw.fsync(handle).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()> {
|
||||
self.raw.write(handle, offset, bytes).await?;
|
||||
Ok(())
|
||||
@@ -298,13 +287,13 @@ impl RemoteFs for SftpClient {
|
||||
|
||||
async fn attributes(
|
||||
&self,
|
||||
handle: &str,
|
||||
path: &RemotePath,
|
||||
modified: Option<u32>,
|
||||
permissions: Option<u32>,
|
||||
) -> Result<()> {
|
||||
self.raw
|
||||
.fsetstat(
|
||||
handle,
|
||||
.setstat(
|
||||
path.as_str(),
|
||||
FileAttributes {
|
||||
mtime: modified,
|
||||
atime: modified,
|
||||
@@ -337,25 +326,8 @@ impl RemoteFs for SftpClient {
|
||||
.filename,
|
||||
)
|
||||
}
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
|
||||
if replace {
|
||||
let mut payload = Vec::new();
|
||||
for value in [from.as_str(), to.as_str()] {
|
||||
payload.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
||||
payload.extend_from_slice(value.as_bytes());
|
||||
}
|
||||
match self
|
||||
.raw
|
||||
.extended("posix-rename@openssh.com", payload)
|
||||
.await?
|
||||
{
|
||||
Packet::Status(status) if status.status_code == StatusCode::Ok => {}
|
||||
Packet::Status(status) => return Err(Error::Status(status).into()),
|
||||
_ => bail!(t!("sftp.client.bad_rename_reply")),
|
||||
}
|
||||
} else {
|
||||
self.raw.rename(from.as_str(), to.as_str()).await?;
|
||||
}
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()> {
|
||||
self.raw.rename(from.as_str(), to.as_str()).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn remove(&self, path: &RemotePath) -> Result<()> {
|
||||
@@ -381,9 +353,6 @@ impl RemoteFs for SftpClient {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
fn atomic_replace(&self) -> bool {
|
||||
self.atomic_replace
|
||||
}
|
||||
}
|
||||
fn metadata(attrs: &FileAttributes) -> FileMetadata {
|
||||
// Compare the whole type field: russh-sftp's `is_dir()` tests one bit,
|
||||
|
||||
+1
-4
@@ -381,10 +381,7 @@ pub(crate) async fn write_in_place<F: RemoteFs>(
|
||||
let message = format!("{error:#}");
|
||||
error.context(SaveFailure::Interrupted(message))
|
||||
};
|
||||
let written = match write_all(fs, &handle, bytes).await {
|
||||
Ok(()) => fs.sync(&handle).await,
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let written = write_all(fs, &handle, bytes).await;
|
||||
let close = fs.close(&handle).await;
|
||||
written.map_err(interrupted)?;
|
||||
close.map_err(interrupted)?;
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(crate) async fn run<F: RemoteFs>(fs: &F, operation: &RemoteOperation) -> Res
|
||||
if fs.metadata(to).await?.is_some() {
|
||||
bail!(t!("sftp.error.exists", name = to.file_name()));
|
||||
}
|
||||
fs.rename(from, to, false).await
|
||||
fs.rename(from, to).await
|
||||
}
|
||||
RemoteOperation::CreateDirectory { path } => {
|
||||
if fs.metadata(path).await?.is_some() {
|
||||
|
||||
@@ -29,6 +29,7 @@ struct TestServer {
|
||||
interrupts: Arc<AtomicUsize>,
|
||||
hangup: Arc<tokio::sync::Notify>,
|
||||
unsupported: bool,
|
||||
bastion: bool,
|
||||
}
|
||||
struct Handler {
|
||||
directory: PathBuf,
|
||||
@@ -39,6 +40,8 @@ struct Handler {
|
||||
/// Ends an SFTP session while nothing is asked of it.
|
||||
hangup: Arc<tokio::sync::Notify>,
|
||||
unsupported: bool,
|
||||
/// Ends the SFTP session on what a bastion host refuses.
|
||||
bastion: bool,
|
||||
channels: HashMap<ChannelId, russh::Channel<server::Msg>>,
|
||||
}
|
||||
impl server::Server for TestServer {
|
||||
@@ -52,6 +55,7 @@ impl server::Server for TestServer {
|
||||
interrupts: self.interrupts.clone(),
|
||||
hangup: self.hangup.clone(),
|
||||
unsupported: self.unsupported,
|
||||
bastion: self.bastion,
|
||||
channels: HashMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -120,6 +124,7 @@ impl server::Handler for Handler {
|
||||
let directory = self.directory.clone();
|
||||
let interrupts = self.interrupts.clone();
|
||||
let hangup = self.hangup.clone();
|
||||
let bastion = self.bastion;
|
||||
tokio::spawn(async move {
|
||||
let executable = if cfg!(target_os = "macos") {
|
||||
"/usr/libexec/sftp-server"
|
||||
@@ -149,6 +154,9 @@ impl server::Handler for Handler {
|
||||
}
|
||||
let mut bytes = vec![0; size as usize];
|
||||
reader.read_exact(&mut bytes).await?;
|
||||
if bastion && refused_by_bastion(&bytes) {
|
||||
return Err::<(), _>(std::io::Error::other("refused by the bastion host"));
|
||||
}
|
||||
// SSH_FXP_WRITE (6) for uploads, SSH_FXP_READ (5) for downloads.
|
||||
if matches!(bytes.first(), Some(&5) | Some(&6)) {
|
||||
writes += 1;
|
||||
@@ -192,7 +200,27 @@ impl Drop for Running {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
/// What a bastion host in front of OpenSSH was seen to refuse, though it
|
||||
/// passes on OpenSSH's offer of extensions: `SSH_FXP_FSETSTAT` (10), which it
|
||||
/// answered with a failure, and `fsync@openssh.com`, on which it ended the
|
||||
/// session. Here every `SSH_FXP_EXTENDED` (200) is refused: like WinSCP,
|
||||
/// ShellRS uses none to transfer or save a file.
|
||||
fn refused_by_bastion(packet: &[u8]) -> bool {
|
||||
matches!(packet.first(), Some(10 | 200))
|
||||
}
|
||||
async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Option<Running> {
|
||||
serve(directory, interrupts, unsupported, false).await
|
||||
}
|
||||
/// A server that ends the SFTP session on what a bastion host refuses.
|
||||
async fn bastion_server(directory: PathBuf) -> Option<Running> {
|
||||
serve(directory, 0, false, true).await
|
||||
}
|
||||
async fn serve(
|
||||
directory: PathBuf,
|
||||
interrupts: usize,
|
||||
unsupported: bool,
|
||||
bastion: bool,
|
||||
) -> Option<Running> {
|
||||
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
|
||||
Ok(l) => l,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
@@ -215,6 +243,7 @@ async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Opt
|
||||
interrupts: interrupts.clone(),
|
||||
hangup: hangup.clone(),
|
||||
unsupported,
|
||||
bastion,
|
||||
};
|
||||
let key = russh::keys::PrivateKey::random(
|
||||
&mut russh::keys::key::safe_rng(),
|
||||
@@ -696,3 +725,109 @@ fn scp_style_transfers_land_where_scp_would_put_them() {
|
||||
assert!(!remote.join("missing").exists());
|
||||
});
|
||||
}
|
||||
|
||||
/// Behind a bastion host that refuses `fsetstat` and extensions, an upload,
|
||||
/// an upload over that file and an editor's save all finish on the one
|
||||
/// connection, and the upload keeps the local file's modification time.
|
||||
#[test]
|
||||
fn uploads_and_saves_get_past_a_bastion_host_that_refuses_fsetstat_and_extensions() {
|
||||
runtime().block_on(async {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let remote = temp.path().join("remote");
|
||||
std::fs::create_dir(&remote).unwrap();
|
||||
let Some(server) = bastion_server(remote.clone()).await else {
|
||||
return;
|
||||
};
|
||||
let worker = worker(server.port, temp.path());
|
||||
let home = loop {
|
||||
match next(&worker).await {
|
||||
SftpEvent::Connected { home } => break home,
|
||||
SftpEvent::Disconnected(e) => panic!("connect failed: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
let source = temp.path().join("README.md");
|
||||
let modified = std::time::UNIX_EPOCH + Duration::from_secs(1_500_000_000);
|
||||
let write_source = |bytes: &[u8]| {
|
||||
std::fs::write(&source, bytes).unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&source)
|
||||
.unwrap()
|
||||
.set_modified(modified)
|
||||
.unwrap();
|
||||
};
|
||||
let upload =
|
||||
|| SftpCommand::Upload(UploadRequest::new(vec![source.clone()], home.clone()).unwrap());
|
||||
let uploaded = remote.join("README.md");
|
||||
|
||||
write_source(b"first");
|
||||
let done = transfer(&worker, upload()).await;
|
||||
assert_eq!(done.phase(), TransferPhase::Completed);
|
||||
assert_eq!(std::fs::read(&uploaded).unwrap(), b"first");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&uploaded).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
|
||||
write_source(b"second");
|
||||
worker.commands.send(upload()).await.unwrap();
|
||||
loop {
|
||||
match next(&worker).await {
|
||||
SftpEvent::Question(question) => {
|
||||
assert_eq!(question.kind(), TransferQuestionKind::Conflict);
|
||||
worker
|
||||
.commands
|
||||
.send(SftpCommand::Answer {
|
||||
request_id: question.id(),
|
||||
answer: TransferAnswer::new(TransferChoice::Overwrite, false),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
SftpEvent::Progress(p) if p.phase() == TransferPhase::Completed => {
|
||||
assert_eq!(p.succeeded(), 1);
|
||||
break;
|
||||
}
|
||||
SftpEvent::Progress(p)
|
||||
if matches!(
|
||||
p.phase(),
|
||||
TransferPhase::Stopped | TransferPhase::Reconnecting
|
||||
) =>
|
||||
{
|
||||
panic!("the upload over the file broke off: {p:?}")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read(&uploaded).unwrap(), b"second");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&uploaded).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
|
||||
worker
|
||||
.commands
|
||||
.send(SftpCommand::WriteFile {
|
||||
request_id: 7,
|
||||
path: home.join("README.md").unwrap(),
|
||||
bytes: b"edited".to_vec(),
|
||||
expected: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
loop {
|
||||
match next(&worker).await {
|
||||
SftpEvent::FileWritten { request_id, result } => {
|
||||
assert_eq!(request_id, 7);
|
||||
result.unwrap();
|
||||
break;
|
||||
}
|
||||
SftpEvent::Disconnected(e) => panic!("the save broke off: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read(&uploaded).unwrap(), b"edited");
|
||||
assert_eq!(server.connections.load(Ordering::SeqCst), 1);
|
||||
});
|
||||
}
|
||||
+39
-52
@@ -41,11 +41,10 @@ struct Remote {
|
||||
reads: Cell<usize>,
|
||||
short_reads: Cell<bool>,
|
||||
renames: Cell<usize>,
|
||||
atomic: bool,
|
||||
denied: RefCell<Option<String>>,
|
||||
}
|
||||
impl Remote {
|
||||
fn new(atomic: bool) -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
nodes: RefCell::new(BTreeMap::new()),
|
||||
fault: Cell::new(None),
|
||||
@@ -55,7 +54,6 @@ impl Remote {
|
||||
reads: Cell::new(0),
|
||||
short_reads: Cell::new(false),
|
||||
renames: Cell::new(0),
|
||||
atomic,
|
||||
denied: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
@@ -225,9 +223,6 @@ impl RemoteFs for Remote {
|
||||
self.writes.set(self.writes.get() + 1);
|
||||
self.fail(Fault::Write(self.writes.get()))
|
||||
}
|
||||
async fn sync(&self, _: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn close(&self, _: &str) -> Result<()> {
|
||||
if self.fault.get() == Some(Fault::ChangeTargetOnClose) {
|
||||
self.fault.set(None);
|
||||
@@ -241,12 +236,12 @@ impl RemoteFs for Remote {
|
||||
}
|
||||
async fn attributes(
|
||||
&self,
|
||||
handle: &str,
|
||||
path: &RemotePath,
|
||||
modified: Option<u32>,
|
||||
permissions: Option<u32>,
|
||||
) -> Result<()> {
|
||||
let mut nodes = self.nodes.borrow_mut();
|
||||
let n = nodes.get_mut(handle).unwrap();
|
||||
let n = nodes.get_mut(path.as_str()).unwrap();
|
||||
n.metadata = FileMetadata::new(
|
||||
EntryKind::File,
|
||||
n.data.len() as u64,
|
||||
@@ -283,13 +278,13 @@ impl RemoteFs for Remote {
|
||||
async fn readlink(&self, path: &RemotePath) -> Result<String> {
|
||||
Ok(self.nodes.borrow()[path.as_str()].link.clone())
|
||||
}
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()> {
|
||||
if self.fault.get() == Some(Fault::RejectRename(self.renames.get() + 1)) {
|
||||
self.fault.set(None);
|
||||
bail!("publish denied");
|
||||
}
|
||||
let mut nodes = self.nodes.borrow_mut();
|
||||
if !replace && nodes.contains_key(to.as_str()) {
|
||||
if nodes.contains_key(to.as_str()) {
|
||||
bail!("target exists");
|
||||
}
|
||||
let node = nodes
|
||||
@@ -331,9 +326,6 @@ impl RemoteFs for Remote {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn atomic_replace(&self) -> bool {
|
||||
self.atomic
|
||||
}
|
||||
}
|
||||
impl Remote {
|
||||
/// The path a link chain ends at, as a server's `open` follows it.
|
||||
@@ -496,7 +488,7 @@ fn recursive_upload_deduplicates_sources_and_preserves_empty_directories_and_lin
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
run(&mut batch, &remote, &answers.control).await.unwrap();
|
||||
assert_eq!(remote.bytes("/dest/中文 目录/large.bin"), data);
|
||||
assert_eq!(remote.bytes("/dest/中文 目录/零 字节"), Vec::<u8>::new());
|
||||
@@ -540,7 +532,7 @@ fn winscp_style_upload_finishes_without_a_verification_phase() {
|
||||
&answers.control,
|
||||
)
|
||||
.await;
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
|
||||
run(&mut batch, &remote, &answers.control).await.unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
@@ -583,7 +575,7 @@ fn resume_uses_remote_filepart_size_and_preserves_old_target() {
|
||||
let data = vec![71; 150_000];
|
||||
std::fs::write(&source, &data).unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(false);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"old file intact");
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite]);
|
||||
@@ -633,7 +625,7 @@ fn filepart_larger_than_source_requires_explicit_restart() {
|
||||
let source = tmp.path().join("file");
|
||||
std::fs::write(&source, vec![1; 100_000]).unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let answers = Answers::new(vec![]);
|
||||
let mut first = batch(&source, journal.clone(), &answers.control).await;
|
||||
@@ -663,7 +655,7 @@ fn winscp_style_resume_does_not_prevalidate_the_source_version() {
|
||||
let data = vec![1; 100_000];
|
||||
std::fs::write(&source, &data).unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let answers = Answers::new(vec![]);
|
||||
let mut first = batch(&source, journal.clone(), &answers.control).await;
|
||||
@@ -700,7 +692,7 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
|
||||
let source = tmp.path().join("file");
|
||||
let data = vec![3; 100_000];
|
||||
std::fs::write(&source, &data).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file.filepart", &data[..32_768]);
|
||||
let answers = Answers::new(vec![TransferChoice::Resume]);
|
||||
let mut upload = batch(
|
||||
@@ -724,17 +716,12 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
|
||||
#[test]
|
||||
fn lost_replies_at_backup_publish_and_close_are_reconciled() {
|
||||
runtime().block_on(async {
|
||||
for (atomic, fault) in [
|
||||
(true, Fault::Rename(1)),
|
||||
(false, Fault::Rename(1)),
|
||||
(false, Fault::Rename(2)),
|
||||
(false, Fault::Close),
|
||||
] {
|
||||
for fault in [Fault::Rename(1), Fault::Rename(2), Fault::Close] {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let source = tmp.path().join("file");
|
||||
std::fs::write(&source, b"new contents").unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(atomic);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"original");
|
||||
remote.fault.set(Some(fault));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite]);
|
||||
@@ -756,7 +743,7 @@ fn cancellation_retains_journal_and_skip_does_not_overwrite() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let source = tmp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"old");
|
||||
let answers = Answers::new(vec![TransferChoice::Skip]);
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
@@ -783,7 +770,7 @@ fn directory_permission_error_is_reported_as_failure() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let source = tmp.path().join("blocked");
|
||||
std::fs::create_dir(&source).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
*remote.denied.borrow_mut() = Some("/dest/blocked".into());
|
||||
let answers = Answers::new(vec![TransferChoice::Skip]);
|
||||
let mut batch = batch(
|
||||
@@ -842,7 +829,7 @@ fn retrying_an_unreadable_folder_adds_only_its_files_to_the_upload() {
|
||||
set_mode(0o755);
|
||||
assert_eq!(batch.meter.progress.total_bytes(), 10_000);
|
||||
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
run(&mut batch, &remote, &answers.control).await.unwrap();
|
||||
assert_eq!(batch.meter.progress.failed(), 0);
|
||||
assert_eq!(batch.meter.progress.total_bytes(), 10_300);
|
||||
@@ -859,7 +846,7 @@ fn retrying_an_unreadable_folder_adds_only_its_files_to_the_download() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let out = tmp.path().join("out");
|
||||
std::fs::create_dir(&out).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.nodes.borrow_mut().insert(
|
||||
"/srv/folder".into(),
|
||||
Node {
|
||||
@@ -1066,7 +1053,7 @@ fn failed_compatibility_publish_restores_old_target_and_reports_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source = temp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(false);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"original");
|
||||
remote.fault.set(Some(Fault::RejectRename(2)));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
|
||||
@@ -1097,7 +1084,7 @@ fn target_changed_during_upload_requires_another_conflict_answer() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source = temp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"original");
|
||||
remote.fault.set(Some(Fault::ChangeTargetOnClose));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
|
||||
@@ -1140,7 +1127,7 @@ fn target_removed_during_upload_still_requires_confirmation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source = temp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"old");
|
||||
remote.fault.set(Some(Fault::RemoveTargetOnClose));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
|
||||
@@ -1170,7 +1157,7 @@ fn remote_path(path: &str) -> RemotePath {
|
||||
#[test]
|
||||
fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/keep", 0o755);
|
||||
remote.file("/keep/important", b"data");
|
||||
remote.dir("/gone", 0o755);
|
||||
@@ -1198,7 +1185,7 @@ fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
|
||||
#[test]
|
||||
fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/site", 0o700);
|
||||
remote.dir("/site/css", 0o700);
|
||||
remote.file("/site/css/a.css", b"a");
|
||||
@@ -1231,7 +1218,7 @@ fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
|
||||
#[test]
|
||||
fn rename_and_create_refuse_existing_names() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/a", b"a");
|
||||
remote.file("/b", b"b");
|
||||
let error = super::operations::run(
|
||||
@@ -1329,7 +1316,7 @@ fn download_copies_trees_links_and_empty_directories_in_order() {
|
||||
for short_reads in [false, true] {
|
||||
let out = tmp.path().join(format!("out-{short_reads}"));
|
||||
std::fs::create_dir(&out).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.short_reads.set(short_reads);
|
||||
remote.dir("/srv/中文 目录", 0o755);
|
||||
remote.file("/srv/中文 目录/large.bin", &data);
|
||||
@@ -1385,7 +1372,7 @@ fn download_resumes_from_the_partial_file_after_a_lost_connection() {
|
||||
runtime().block_on(async {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let data: Vec<u8> = (0..300_000).map(|i| (i % 253) as u8).collect();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/big", &data);
|
||||
remote.fault.set(Some(Fault::Read(5)));
|
||||
let journal = DownloadJournal::new(tmp.path().join("journal"));
|
||||
@@ -1440,7 +1427,7 @@ fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/file", b"new contents");
|
||||
let journal = DownloadJournal::new(tmp.path().join("journal"));
|
||||
let answers = Answers::new(vec![TransferChoice::Skip]);
|
||||
@@ -1484,7 +1471,7 @@ fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
|
||||
fn a_changed_remote_file_is_not_resumed_and_discard_cleans_up() {
|
||||
runtime().block_on(async {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/file", &vec![7; 200_000]);
|
||||
remote.fault.set(Some(Fault::Read(4)));
|
||||
let journal = DownloadJournal::new(tmp.path().join("journal"));
|
||||
@@ -1554,7 +1541,7 @@ fn a_changed_remote_file_is_not_resumed_and_discard_cleans_up() {
|
||||
fn download_cancellation_keeps_the_partial_file() {
|
||||
runtime().block_on(async {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/file", &vec![1; 100_000]);
|
||||
let answers = Answers::new(vec![]);
|
||||
let mut batch = download_batch(
|
||||
@@ -1745,7 +1732,7 @@ fn an_scp_upload_takes_the_destination_name_and_keeps_the_execute_bits() {
|
||||
let source = tmp.path().join("build.sh");
|
||||
std::fs::write(&source, b"#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
|
||||
let request = UploadRequest::scp(source.clone(), remote_path("/dest/deploy.sh"))
|
||||
.resolved(remote_path("/dest"), Some("deploy.sh".into()));
|
||||
@@ -1797,7 +1784,7 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let out = tmp.path().join("out");
|
||||
std::fs::create_dir(&out).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/srv/run.sh", b"#!/bin/sh\n");
|
||||
remote
|
||||
.nodes
|
||||
@@ -1856,7 +1843,7 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
|
||||
#[test]
|
||||
fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
let text: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
|
||||
remote.file("/etc/app.conf", text.as_bytes());
|
||||
remote.short_reads.set(true);
|
||||
@@ -1875,7 +1862,7 @@ fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
|
||||
#[test]
|
||||
fn a_link_is_followed_to_the_file_it_points_at() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/srv/real.conf", b"old\n");
|
||||
remote.link("/etc/app.conf", "/srv/real.conf");
|
||||
let path = remote_path("/etc/app.conf");
|
||||
@@ -1896,7 +1883,7 @@ fn a_link_is_followed_to_the_file_it_points_at() {
|
||||
#[test]
|
||||
fn directories_large_files_and_binary_files_are_refused() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/etc", 0o755);
|
||||
remote.file("/var/big.log", &vec![b'x'; EDIT_LIMIT as usize + 1]);
|
||||
let mut binary = vec![b'a'; 1_000_000];
|
||||
@@ -1924,7 +1911,7 @@ fn directories_large_files_and_binary_files_are_refused() {
|
||||
#[test]
|
||||
fn a_save_writes_in_place_and_keeps_the_permissions() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
let long: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8 + 1).collect();
|
||||
remote.file("/etc/app.conf", &long);
|
||||
let path = remote_path("/etc/app.conf");
|
||||
@@ -1952,7 +1939,7 @@ fn a_save_writes_in_place_and_keeps_the_permissions() {
|
||||
#[test]
|
||||
fn a_save_over_a_file_changed_or_removed_since_is_refused() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/etc/app.conf", b"mine\n");
|
||||
let path = remote_path("/etc/app.conf");
|
||||
let file = read_text(&remote, &path).await.unwrap();
|
||||
@@ -1980,7 +1967,7 @@ fn a_save_over_a_file_changed_or_removed_since_is_refused() {
|
||||
#[test]
|
||||
fn a_save_that_fails_after_truncating_is_interrupted() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/etc/app.conf", &vec![b'a'; 200_000]);
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let error = write_in_place(
|
||||
@@ -2123,7 +2110,7 @@ fn local_files_are_edited_in_place_through_links() {
|
||||
#[test]
|
||||
fn a_binary_file_is_read_whole_for_a_preview_within_its_limit() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
let image: Vec<u8> = (0..300_000u32).map(|i| (i % 256) as u8).collect();
|
||||
remote.file("/srv/photo.png", &image);
|
||||
remote.short_reads.set(true);
|
||||
@@ -2169,7 +2156,7 @@ fn a_sync_copies_only_what_changed_since_the_last() {
|
||||
.unwrap();
|
||||
std::fs::write(dist.join("assets/logo.png"), b"png").unwrap();
|
||||
std::os::unix::fs::symlink("index.html", dist.join("home.html")).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/srv", 0o755);
|
||||
|
||||
let answers = Answers::new(vec![]);
|
||||
@@ -2217,7 +2204,7 @@ fn a_sync_with_delete_removes_what_is_not_here_and_nothing_it_cannot_see() {
|
||||
std::fs::set_permissions(dist.join("locked"), std::fs::Permissions::from_mode(0o000))
|
||||
.unwrap();
|
||||
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/srv/app", 0o755);
|
||||
remote.file("/srv/app/keep.txt", b"k");
|
||||
remote.file("/srv/app/extra.txt", b"x");
|
||||
|
||||
+29
-37
@@ -532,25 +532,6 @@ impl UploadBatch {
|
||||
uploaded += length;
|
||||
self.meter.advance(length, uploaded, control);
|
||||
}
|
||||
control
|
||||
.run(
|
||||
fs.attributes(
|
||||
&handle,
|
||||
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
|
||||
record
|
||||
.original
|
||||
.as_ref()
|
||||
.and_then(FileMetadata::permissions)
|
||||
.or_else(|| {
|
||||
self.preserve_mode
|
||||
.then(|| item.metadata.permissions())
|
||||
.flatten()
|
||||
.map(|mode| mode & 0o777)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
control.run(fs.sync(&handle)).await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
@@ -559,7 +540,24 @@ impl UploadBatch {
|
||||
let close = fs.close(&handle).await;
|
||||
result?;
|
||||
close?;
|
||||
Ok(())
|
||||
control
|
||||
.run(
|
||||
fs.attributes(
|
||||
&record.temporary,
|
||||
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
|
||||
record
|
||||
.original
|
||||
.as_ref()
|
||||
.and_then(FileMetadata::permissions)
|
||||
.or_else(|| {
|
||||
self.preserve_mode
|
||||
.then(|| item.metadata.permissions())
|
||||
.flatten()
|
||||
.map(|mode| mode & 0o777)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn matches_expected_item<F: RemoteFs>(
|
||||
&self,
|
||||
@@ -603,7 +601,7 @@ impl UploadBatch {
|
||||
if fs.metadata(&record.target).await?.is_none()
|
||||
&& fs.metadata(&record.backup).await?.is_some()
|
||||
{
|
||||
fs.rename(&record.backup, &record.target, false).await?;
|
||||
fs.rename(&record.backup, &record.target).await?;
|
||||
}
|
||||
bail!(t!("sftp.upload.publish_unconfirmed"));
|
||||
}
|
||||
@@ -635,24 +633,22 @@ impl UploadBatch {
|
||||
record.original = current;
|
||||
self.journal.save(record).await?;
|
||||
if let Some(source_metadata) = &record.source_metadata {
|
||||
let handle = control.run(fs.open(&record.temporary, false)).await?;
|
||||
let result = control
|
||||
control
|
||||
.run(fs.attributes(
|
||||
&handle,
|
||||
&record.temporary,
|
||||
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
|
||||
record.original.as_ref().and_then(FileMetadata::permissions),
|
||||
))
|
||||
.await;
|
||||
let closed = fs.close(&handle).await;
|
||||
result?;
|
||||
closed?;
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
if backup.is_none() && record.original.is_some() && !fs.atomic_replace() {
|
||||
// Over a file, the old one is moved aside first and removed once
|
||||
// the new one is in its place: no `posix-rename@openssh.com`.
|
||||
if backup.is_none() && record.original.is_some() {
|
||||
record.phase = PublishPhase::BackingUp;
|
||||
self.journal.save(record).await?;
|
||||
control
|
||||
.run(fs.rename(&record.target, &record.backup, false))
|
||||
.run(fs.rename(&record.target, &record.backup))
|
||||
.await?;
|
||||
record.phase = PublishPhase::BackedUp;
|
||||
self.journal.save(record).await?;
|
||||
@@ -660,11 +656,7 @@ impl UploadBatch {
|
||||
record.phase = PublishPhase::Publishing;
|
||||
self.journal.save(record).await?;
|
||||
let result = control
|
||||
.run(fs.rename(
|
||||
&record.temporary,
|
||||
&record.target,
|
||||
fs.atomic_replace() && record.original.is_some() && backup.is_none(),
|
||||
))
|
||||
.run(fs.rename(&record.temporary, &record.target))
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
if !super::client::is_network_error(&error)
|
||||
@@ -672,7 +664,7 @@ impl UploadBatch {
|
||||
&& fs.metadata(&record.target).await?.is_none()
|
||||
&& fs.metadata(&record.backup).await?.is_some()
|
||||
{
|
||||
fs.rename(&record.backup, &record.target, false).await?;
|
||||
fs.rename(&record.backup, &record.target).await?;
|
||||
record.phase = PublishPhase::Ready;
|
||||
self.journal.save(record).await?;
|
||||
}
|
||||
@@ -701,7 +693,7 @@ impl UploadBatch {
|
||||
)?;
|
||||
if fs.metadata(&record.backup).await?.is_some() {
|
||||
if fs.metadata(&record.target).await?.is_none() {
|
||||
fs.rename(&record.backup, &record.target, false).await?;
|
||||
fs.rename(&record.backup, &record.target).await?;
|
||||
} else {
|
||||
bail!(t!("sftp.upload.backup_and_target"));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user