Phase 10: SFTP VFS full read-write surface — mkdir, remove, rename

SFTP backend now implements the complete Vfs trait with zero
Unsupported stubs. Three new operations:

- mkdir: creates directories via session.create_dir(). Supports
  parents=true by walking path components and creating each missing
  parent directory step-by-step via SFTP protocol calls.

- remove: deletes files and directories via session.remove_file() /
  session.remove_dir(). Supports recursive=true by listing children
  with read_dir() and removing them depth-first before the directory.

- rename: renames/moves via session.rename(oldpath, newpath).

Fixed 2 pre-existing warnings:
- Removed unused std::path::PathBuf import
- Added doc comment on SshHandlerError::KeyMismatch.host field

Updated module doc to reflect full read-write surface.
Updated README.md Phase 7 VFS status: 🚧 partial →  complete.
1433 tests passing, zero warnings with --features sftp.
This commit is contained in:
2026-07-08 18:32:01 +03:00
parent 5c3734f884
commit 103a6389a1
3 changed files with 103 additions and 8 deletions
+18
View File
@@ -222,6 +222,24 @@ All previously deferred items are now resolved:
## 4. RECENT CHANGELOG (last 6 months, condensed)
### 2026-07-08 — Phase 10 — SFTP VFS full read-write surface
**SFTP mkdir, remove, rename implemented:**
- `mkdir`: creates a directory on the remote SFTP server via
`session.create_dir()`. Supports `parents=true` — walks path
components upward, creating each missing parent directory.
- `remove`: deletes files and directories. Supports `recursive=true`
lists child entries via `read_dir()` and removes them depth-first
before removing the target directory.
- `rename`: renames/moves files and directories via
`session.rename(oldpath, newpath)`.
- Fixed 2 pre-existing warnings (unused `PathBuf` import, missing doc
on `SshHandlerError::KeyMismatch.host` field).
The SFTP VFS backend now exposes the full Vfs trait surface:
`read_dir`, `stat`, `exists`, `is_dir`, `is_file`, `open_read`,
`open_write`, `mkdir`, `remove`, `rename` — zero `Unsupported` stubs.
### 2026-07-08 — W79 — MC visual/behavioral parity polish
**W79a Viewer:** Two MC parity improvements:
+1 -1
View File
@@ -155,7 +155,7 @@ dialog. Selection persists to `~/.config/tlc/config.toml`.
| 4 keymap + dispatcher | ✅ |
| 5 editor polish | ✅ |
| 6 filemanager + viewer extras | ✅ |
| 7 VFS (local + remote backends) | 🚧 partial |
| 7 VFS (local + remote backends) | ✅ SFTP full read-write, FTP, tar/cpio/zip/extfs read-only |
| 8 archives + skin + i18n | ✅ skin + i18n (8 built-in skins, Alt-S); archives 🚧 |
| 13 skins + runtime selection | ✅ 8 built-in skins, Alt-S dialog, config persistence |
| 14a critical features | ✅ menu bar (F9), select/unselect group, quick cd, Ctrl-O sub-shell, Alt-Enter cmdline |
+84 -7
View File
@@ -6,12 +6,9 @@
//! (matching the rest of TLC's Vfs trait) and dispatches onto the
//! runtime via [`SftpVfs::block_on`].
//!
//! The backend is read-mostly: `read_dir`, `stat`, `exists`,
//! `is_dir`, `is_file`, `open_read` are implemented. `open_write`,
//! `mkdir`, `remove`, and `rename` inherit the
//! [`VfsError::Unsupported`] default. (`open_write` could be added
//! in a later phase — it is a one-liner against
//! `SftpSession::create`.)
//! The backend provides the full read-write surface: `read_dir`,
//! `stat`, `exists`, `is_dir`, `is_file`, `open_read`, `open_write`,
//! `mkdir`, `remove`, and `rename` are all implemented.
//!
//! Host-key verification uses the [`crate::vfs::known_hosts`] store
//! at `~/.config/tlc/known_hosts` (or `$XDG_CONFIG_HOME/tlc/known_hosts`).
@@ -26,7 +23,6 @@
#![cfg(feature = "sftp")]
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
@@ -50,6 +46,7 @@ use crate::vfs::traits::{Vfs, VfsError};
pub enum SshHandlerError {
/// Host is in the store but the key changed (possible MITM).
KeyMismatch {
/// Remote hostname (e.g. `"example.com"`).
host: String,
/// Algorithm name (e.g. `"ssh-ed25519"`).
algo: String,
@@ -417,6 +414,86 @@ impl Vfs for SftpVfs {
flushed: false,
}))
}
fn mkdir(&self, p: &VfsPath, parents: bool) -> Result<(), VfsError> {
let path = sftp_path(p)?;
let session = Arc::clone(&self.session);
if parents {
// Walk up the path, creating each missing parent directory.
let mut components: Vec<&str> =
path.split('/').filter(|c| !c.is_empty()).collect();
let mut current = String::new();
// Skip the last component — we create it last via
// session.create_dir.
if let Some(_target) = components.pop() {
for component in &components {
if current.is_empty() {
current.push('/');
}
current.push_str(component);
let dir = current.clone();
let exists = self.block_on({
let s = Arc::clone(&session);
let d = dir.clone();
async move { s.try_exists(&d).await }
})
.unwrap_or(false);
if !exists {
self.block_on({
let s = Arc::clone(&session);
let d = dir.clone();
async move {
s.create_dir(&d).await.map_err(|e| {
VfsError::Other(format!("sftp mkdir parent: {e}"))
})
}
})?;
}
current.push('/');
}
}
}
self.block_on(async move {
session.create_dir(&path).await
.map_err(|e| VfsError::Other(format!("sftp mkdir: {e}")))
})
}
fn remove(&self, p: &VfsPath, recursive: bool) -> Result<(), VfsError> {
let path = sftp_path(p)?;
let session = Arc::clone(&self.session);
let is_dir = self.is_dir(p).unwrap_or(false);
if is_dir {
if recursive {
// List contents and remove each child first, then
// the directory itself.
let children = self.read_dir(p, true)?;
for child in &children {
let child_path = p.join(&child.name);
self.remove(&child_path, true)?;
}
}
self.block_on(async move {
session.remove_dir(&path).await
.map_err(|e| VfsError::Other(format!("sftp rmdir: {e}")))
})
} else {
self.block_on(async move {
session.remove_file(&path).await
.map_err(|e| VfsError::Other(format!("sftp rm: {e}")))
})
}
}
fn rename(&self, from: &VfsPath, to: &VfsPath) -> Result<(), VfsError> {
let from_path = sftp_path(from)?;
let to_path = sftp_path(to)?;
let session = Arc::clone(&self.session);
self.block_on(async move {
session.rename(&from_path, &to_path).await
.map_err(|e| VfsError::Other(format!("sftp rename: {e}")))
})
}
}
impl Drop for SftpVfs {