Files
shell-rs/src/sftp/upload.rs
T

702 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::{
EntryKind, FileMetadata, RemotePath, SftpEvent, TransferChoice, TransferPhase,
TransferProgress, TransferQuestionKind, UploadRequest,
client::RemoteFs,
control::{TargetGuard, TransferControl},
journal::{Journal, PublishPhase, ResumeRecord, SourceMetadata},
model::local_metadata,
};
use anyhow::{Result, anyhow, bail};
use futures::{StreamExt as _, stream::FuturesUnordered};
use std::{
collections::HashSet,
path::PathBuf,
time::{Duration, Instant},
};
use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _};
const CHUNK: usize = 32 * 1024;
const MAX_IN_FLIGHT: usize = 16;
#[derive(Clone)]
struct UploadItem {
source: PathBuf,
target: RemotePath,
metadata: FileMetadata,
error: Option<String>,
}
pub(crate) struct UploadBatch {
items: Vec<UploadItem>,
cursor: usize,
pub progress: TransferProgress,
endpoint: String,
host_key: String,
journal: Journal,
all_conflicts: Option<TransferChoice>,
approved_resumes: HashSet<RemotePath>,
blocked_directories: Vec<RemotePath>,
started: Instant,
sent_bytes: u64,
completed_bytes: u64,
last_progress: Instant,
}
impl UploadBatch {
pub async fn scan(
request: &UploadRequest,
endpoint: &str,
host_key: &str,
journal: Journal,
control: &TransferControl,
) -> Result<Self> {
let mut sources = Vec::new();
for source in request.sources() {
let absolute = std::path::absolute(source)?;
// Resolve parent aliases while preserving the leaf symlink itself.
let path = if let (Some(parent), Some(name)) = (absolute.parent(), absolute.file_name())
{
match tokio::fs::canonicalize(parent).await {
Ok(parent) => parent.join(name),
Err(_) => absolute,
}
} else {
absolute
};
sources.push(path);
}
sources.sort();
sources.dedup();
let mut roots: Vec<PathBuf> = Vec::new();
for source in sources {
if !roots.iter().any(|parent| source.starts_with(parent)) {
roots.push(source);
}
}
let mut stack = Vec::new();
for source in roots.into_iter().rev() {
let name = source
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("来源文件名不是有效的 UTF-8"))?;
let target = request.destination().join(name)?;
stack.push((source, target));
}
let mut items = Vec::new();
let mut total_bytes: u64 = 0;
while let Some((source, target)) = stack.pop() {
control.check()?;
let metadata = match tokio::fs::symlink_metadata(&source).await {
Ok(metadata) => local_metadata(&metadata),
Err(error) => {
items.push(UploadItem {
source,
target,
metadata: FileMetadata::new(EntryKind::Other, 0, None, None),
error: Some(error.to_string()),
});
continue;
}
};
let mut scan_error = None;
if metadata.kind() == EntryKind::Directory {
let read: Result<Vec<(PathBuf, RemotePath)>> = async {
let mut dir = tokio::fs::read_dir(&source).await?;
let mut children = Vec::new();
while let Some(entry) = dir.next_entry().await? {
let name = entry
.file_name()
.into_string()
.map_err(|_| anyhow!("目录包含无法用 UTF-8 表示的文件名"))?;
children.push((entry.path(), target.join(&name)?));
}
children.sort_by(|a, b| a.0.cmp(&b.0));
Ok(children)
}
.await;
match read {
Ok(children) => stack.extend(children.into_iter().rev()),
Err(e) => scan_error = Some(e.to_string()),
}
} else if metadata.kind() == EntryKind::File {
total_bytes = total_bytes.saturating_add(metadata.size());
}
items.push(UploadItem {
source,
target,
metadata,
error: scan_error,
});
}
let progress = TransferProgress {
total: items.len(),
total_bytes,
..TransferProgress::default()
};
Ok(Self {
items,
cursor: 0,
progress,
endpoint: endpoint.into(),
host_key: host_key.into(),
journal,
all_conflicts: None,
approved_resumes: HashSet::new(),
blocked_directories: Vec::new(),
started: Instant::now(),
sent_bytes: 0,
completed_bytes: 0,
last_progress: Instant::now(),
})
}
pub fn verify_host(&self, fingerprint: &str) -> Result<()> {
if self.host_key != fingerprint {
bail!("服务器主机指纹已改变,已停止续传");
}
Ok(())
}
pub fn is_complete(&self) -> bool {
self.cursor >= self.items.len()
}
pub fn emit(&self, control: &TransferControl) {
let _ = control
.events
.try_send(SftpEvent::Progress(self.progress.clone()));
}
pub fn phase(&mut self, phase: TransferPhase, control: &TransferControl) {
self.progress.phase = phase;
self.emit(control);
}
pub async fn run<F: RemoteFs>(&mut self, fs: &F, control: &TransferControl) -> Result<()> {
self.phase(TransferPhase::Transferring, control);
while self.cursor < self.items.len() {
control.check()?;
let item = self.items[self.cursor].clone();
self.progress.current = item.target.to_string();
self.emit(control);
let _target_guard = TargetGuard::acquire(
format!("{}\0{}", self.endpoint, item.target),
"其他会话正在上传同一目标,请稍后继续上传",
)?;
let result = if self
.blocked_directories
.iter()
.any(|p| item.target.as_str().starts_with(&format!("{p}/")))
{
Err(anyhow!("父目录未创建,无法上传"))
} else if let Some(error) = &item.error {
Err(anyhow!("{error}"))
} else {
self.upload_item(fs, &item, control).await
};
match result {
Ok(true) => {
self.progress.succeeded += 1;
self.progress
.details
.push(format!("已上传:{}", item.target));
if item.metadata.kind() == EntryKind::File {
self.completed_bytes =
self.completed_bytes.saturating_add(item.metadata.size());
}
}
Ok(false) => {
self.progress.skipped += 1;
self.progress
.details
.push(format!("已跳过:{}", item.target));
}
Err(error)
if error.is::<super::control::Cancelled>()
|| super::client::is_network_error(&error) =>
{
return Err(error);
}
Err(error) => {
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
TransferQuestionKind::Error,
item.target.as_str(),
&format!("无法上传:{error:#}"),
)
.await?;
if answer.choice() == TransferChoice::Retry {
if item.error.is_some() {
let request = UploadRequest::new(
vec![item.source.clone()],
item.target.parent(),
)?;
let scanned = Self::scan(
&request,
&self.endpoint,
&self.host_key,
self.journal.clone(),
control,
)
.await?;
self.progress.total =
self.progress.total.saturating_sub(1) + scanned.items.len();
self.progress.total_bytes = self
.progress
.total_bytes
.saturating_sub(item.metadata.size())
+ scanned.progress.total_bytes;
self.items.splice(self.cursor..=self.cursor, scanned.items);
}
continue;
}
self.progress.failed += 1;
self.progress
.details
.push(format!("失败:{}:{error:#}", item.target));
if item.metadata.kind() == EntryKind::Directory {
self.blocked_directories.push(item.target.clone());
}
}
}
self.cursor += 1;
self.progress.completed_bytes = self.completed_bytes;
self.phase(TransferPhase::Transferring, control);
}
self.phase(TransferPhase::Completed, control);
Ok(())
}
async fn approve(
&mut self,
target: &RemotePath,
original: &Option<FileMetadata>,
changed: bool,
control: &TransferControl,
) -> Result<bool> {
if original.is_none() && !changed {
return Ok(true);
}
if original
.as_ref()
.is_some_and(|m| m.kind() == EntryKind::Directory)
{
bail!("目录与文件类型冲突,不会删除远程目录");
}
if !changed && let Some(choice) = self.all_conflicts {
return Ok(choice == TransferChoice::Overwrite);
}
self.phase(TransferPhase::Waiting, control);
let message = match original {
Some(metadata) => format!(
"{}远程项目已存在({} 字节,修改时间 {:?})。覆盖会替换这个文件或链接。",
if changed {
"目标在上传期间发生变化。"
} else {
""
},
metadata.size(),
metadata.modified()
),
None => "原目标在上传期间已被删除或移走。是否继续发布此文件?".into(),
};
let answer = control
.ask(TransferQuestionKind::Conflict, target.as_str(), &message)
.await?;
if answer.apply_to_all() && !changed {
self.all_conflicts = Some(answer.choice());
}
self.phase(TransferPhase::Transferring, control);
Ok(answer.choice() == TransferChoice::Overwrite)
}
async fn upload_item<F: RemoteFs>(
&mut self,
fs: &F,
item: &UploadItem,
control: &TransferControl,
) -> Result<bool> {
let existing_record = self
.journal
.load(&self.endpoint, &item.source, &item.target)
.await?;
if item.metadata.kind() == EntryKind::Directory {
if let Some(record) = &existing_record {
record.validate(&self.endpoint, &self.host_key, &item.source, &item.target)?;
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
TransferQuestionKind::InvalidResume,
item.target.as_str(),
"来源已变为目录,请丢弃旧文件进度后重新上传,或跳过。",
)
.await?;
if answer.choice() != TransferChoice::Restart {
return Ok(false);
}
self.discard_record(fs, record).await?;
}
return match control.run(fs.metadata(&item.target)).await? {
Some(metadata) if metadata.kind() == EntryKind::Directory => Ok(true),
Some(_) => bail!("目标已存在且不是目录,不会跟随或替换链接"),
None => {
control.run(fs.mkdir(&item.target)).await?;
Ok(true)
}
};
}
if item.metadata.kind() == EntryKind::Other {
bail!("不支持上传设备、套接字或其他特殊文件");
}
let mut record = if let Some(record) = existing_record {
record.validate(&self.endpoint, &self.host_key, &item.source, &item.target)?;
if !self.approved_resumes.contains(&item.target) {
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
TransferQuestionKind::Resume,
item.target.as_str(),
"发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。",
)
.await?;
match answer.choice() {
TransferChoice::Skip => return Ok(false),
TransferChoice::Restart => {
self.discard_record(fs, &record).await?;
return Box::pin(self.upload_item(fs, item, control)).await;
}
_ => {}
}
}
record
} else {
let original = control.run(fs.metadata(&item.target)).await?;
if !self
.approve(&item.target, &original, false, control)
.await?
{
return Ok(false);
}
let mut record = ResumeRecord::new(
&self.endpoint,
&self.host_key,
&item.source,
item.target.clone(),
original,
)?;
if item.metadata.kind() == EntryKind::File {
record.source_metadata =
Some(control.run(SourceMetadata::read(&item.source)).await?);
if control.run(fs.metadata(&record.temporary)).await?.is_some() {
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
TransferQuestionKind::Resume,
item.target.as_str(),
"发现未完成的 .filepart 文件。续传会按它的现有大小跳过本地文件前缀,请确认本地文件仍是同一版本。",
)
.await?;
match answer.choice() {
TransferChoice::Skip => return Ok(false),
TransferChoice::Restart => {
control.run(fs.remove(&record.temporary)).await?;
}
_ => {}
}
self.phase(TransferPhase::Transferring, control);
}
} else {
record.link_target = Some(
tokio::fs::read_link(&item.source)
.await?
.into_os_string()
.into_string()
.map_err(|_| anyhow!("链接目标不是有效的 UTF-8"))?,
);
}
self.journal.save(&record).await?;
record
};
self.approved_resumes.insert(item.target.clone());
if record.phase != PublishPhase::Writing {
return self.recover_publish(fs, &mut record, control).await;
}
let uploaded = if item.metadata.kind() == EntryKind::File {
record.source_metadata = Some(control.run(SourceMetadata::read(&item.source)).await?);
self.journal.save(&record).await?;
self.upload_file(fs, item, &mut record, control).await
} else {
let target = record
.link_target
.as_ref()
.ok_or_else(|| anyhow!("续传记录中缺少链接目标"))?;
if let Some(existing) = control.run(fs.metadata(&record.temporary)).await? {
if existing.kind() != EntryKind::Symlink
|| control.run(fs.readlink(&record.temporary)).await? != *target
{
bail!("临时链接已变化,无法继续上传");
}
} else {
control.run(fs.symlink(target, &record.temporary)).await?;
}
Ok(())
};
if let Err(error) = uploaded {
if error.is::<InvalidResume>() {
self.phase(TransferPhase::Waiting, control);
let answer = control
.ask(
TransferQuestionKind::InvalidResume,
item.target.as_str(),
&error.to_string(),
)
.await?;
if answer.choice() == TransferChoice::Restart {
self.discard_record(fs, &record).await?;
return Box::pin(self.upload_item(fs, item, control)).await;
}
return Ok(false);
}
return Err(error);
}
record.phase = PublishPhase::Ready;
self.journal.save(&record).await?;
self.recover_publish(fs, &mut record, control).await
}
async fn upload_file<F: RemoteFs>(
&mut self,
fs: &F,
item: &UploadItem,
record: &mut ResumeRecord,
control: &TransferControl,
) -> Result<()> {
let source_metadata = record
.source_metadata
.clone()
.ok_or_else(|| anyhow!("续传记录中缺少来源文件信息"))?;
let remote = control.run(fs.metadata(&record.temporary)).await?;
if remote.as_ref().is_some_and(|m| m.kind() != EntryKind::File) {
return Err(InvalidResume("远端临时文件类型已变化").into());
}
let resume_offset = remote.as_ref().map(FileMetadata::size).unwrap_or(0);
if resume_offset > source_metadata.size {
return Err(InvalidResume("远端续传偏移大于本地文件").into());
}
let handle = control
.run(fs.open(&record.temporary, remote.is_none()))
.await?;
let result: Result<()> = async {
let mut local = tokio::fs::File::open(&item.source).await?;
local.seek(std::io::SeekFrom::Start(resume_offset)).await?;
self.phase(TransferPhase::Transferring, control);
let mut next_offset = resume_offset;
let mut uploaded = resume_offset;
let mut writes = FuturesUnordered::new();
while next_offset < source_metadata.size || !writes.is_empty() {
while next_offset < source_metadata.size && writes.len() < MAX_IN_FLIGHT {
control.check()?;
let length = (source_metadata.size - next_offset).min(CHUNK as u64) as usize;
let mut bytes = vec![0; length];
local.read_exact(&mut bytes).await?;
let write_offset = next_offset;
next_offset += length as u64;
let handle = handle.as_str();
writes.push(async move {
control.run(fs.write(handle, write_offset, bytes)).await?;
Ok::<_, anyhow::Error>(length as u64)
});
}
let length = writes
.next()
.await
.ok_or_else(|| anyhow!("上传写入流水线意外结束"))??;
self.sent_bytes += length;
uploaded += length;
self.progress.completed_bytes = self.completed_bytes.saturating_add(uploaded);
self.progress.bytes_per_second = (self.sent_bytes as f64
/ self.started.elapsed().as_secs_f64().max(0.001))
as u64;
if self.last_progress.elapsed() >= Duration::from_millis(50) {
self.emit(control);
self.last_progress = Instant::now();
}
}
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),
))
.await?;
control.run(fs.sync(&handle)).await?;
Ok(())
}
.await;
// Always close. After a broken connection, WinSCP-style resume uses the
// .filepart size reported by the server as the next source offset.
let close = fs.close(&handle).await;
result?;
close?;
Ok(())
}
async fn matches_expected_item<F: RemoteFs>(
&self,
fs: &F,
path: &RemotePath,
record: &ResumeRecord,
control: &TransferControl,
) -> Result<bool> {
let Some(metadata) = control.run(fs.metadata(path)).await? else {
return Ok(false);
};
if let Some(target) = &record.link_target {
return Ok(metadata.kind() == EntryKind::Symlink
&& control.run(fs.readlink(path)).await? == *target);
}
let Some(source_metadata) = &record.source_metadata else {
return Ok(false);
};
Ok(metadata.kind() == EntryKind::File && metadata.size() == source_metadata.size)
}
async fn recover_publish<F: RemoteFs>(
&mut self,
fs: &F,
record: &mut ResumeRecord,
control: &TransferControl,
) -> Result<bool> {
let temporary = control.run(fs.metadata(&record.temporary)).await?;
if temporary.is_none() {
if matches!(
record.phase,
PublishPhase::Publishing | PublishPhase::Published
) && self
.matches_expected_item(fs, &record.target, record, control)
.await?
{
self.cleanup(fs, record).await?;
return Ok(true);
}
// We cannot infer success from a lost reply. A backup may still be
// the only copy of the old file; restore only into an absent target.
if fs.metadata(&record.target).await?.is_none()
&& fs.metadata(&record.backup).await?.is_some()
{
fs.rename(&record.backup, &record.target, false).await?;
}
bail!("无法确认文件发布结果,已保留恢复文件;请检查远端目录");
}
if !self
.matches_expected_item(fs, &record.temporary, record, control)
.await?
{
bail!("临时文件大小或类型已变化,未替换目标");
}
let backup = control.run(fs.metadata(&record.backup)).await?;
let current = control.run(fs.metadata(&record.target)).await?;
if backup.is_some() {
if current.is_some() {
bail!("替换期间目标被其他操作创建,已保留目标、备份和临时文件");
}
if backup != record.original {
bail!("恢复备份已变化,停止替换");
}
record.phase = PublishPhase::BackedUp;
self.journal.save(record).await?;
} else if current != record.original {
if !self
.approve(&record.target, &current, true, control)
.await?
{
self.discard_record(fs, record).await?;
return Ok(false);
}
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
.run(fs.attributes(
&handle,
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?;
}
}
if backup.is_none() && record.original.is_some() && !fs.atomic_replace() {
record.phase = PublishPhase::BackingUp;
self.journal.save(record).await?;
control
.run(fs.rename(&record.target, &record.backup, false))
.await?;
record.phase = PublishPhase::BackedUp;
self.journal.save(record).await?;
}
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(),
))
.await;
if let Err(error) = result {
if !super::client::is_network_error(&error)
&& !error.is::<super::control::Cancelled>()
&& fs.metadata(&record.target).await?.is_none()
&& fs.metadata(&record.backup).await?.is_some()
{
fs.rename(&record.backup, &record.target, false).await?;
record.phase = PublishPhase::Ready;
self.journal.save(record).await?;
}
return Err(error);
}
record.phase = PublishPhase::Published;
self.journal.save(record).await?;
self.cleanup(fs, record).await?;
Ok(true)
}
async fn cleanup<F: RemoteFs>(&self, fs: &F, record: &ResumeRecord) -> Result<()> {
if let Some(backup) = fs.metadata(&record.backup).await? {
if Some(backup) != record.original {
bail!("备份已变化,未清理;上传的目标文件已发布");
}
fs.remove(&record.backup).await?;
}
self.journal.remove(record).await
}
async fn discard_record<F: RemoteFs>(&self, fs: &F, record: &ResumeRecord) -> Result<()> {
record.validate(
&self.endpoint,
&self.host_key,
&record.source,
&record.target,
)?;
if fs.metadata(&record.backup).await?.is_some() {
if fs.metadata(&record.target).await?.is_none() {
fs.rename(&record.backup, &record.target, false).await?;
} else {
bail!("存在恢复备份和目标文件,请先检查远端目录;未删除任何文件");
}
}
fs.remove(&record.temporary).await?;
self.journal.remove(record).await
}
pub async fn discard<F: RemoteFs>(&self, fs: &F) -> Result<()> {
for item in &self.items {
if let Some(record) = self
.journal
.load(&self.endpoint, &item.source, &item.target)
.await?
{
self.discard_record(fs, &record).await?;
}
}
Ok(())
}
}
#[derive(Debug)]
struct InvalidResume(&'static str);
impl std::fmt::Display for InvalidResume {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for InvalidResume {}