Sicher übertragen mit SFTP – die Fähre bekommt ein Schloss
FTP schickt alles im Klartext durchs Netz, auch dein Passwort. Heute lernt der Fährmann SFTP: dieselben Aufgaben, aber verschlüsselt über SSH, so wie Profis es heute machen. Damit beide Sprachen nebeneinander funktionieren, führen wir eine gemeinsame „Leitung“ ein, hinter der sich FTP oder SFTP verstecken kann. Das Schöne: Hochladen, Herunterladen, Ordner und Rechte aus Etappe 10 funktionieren danach mit beiden, ohne dass wir sie noch einmal schreiben müssen.
Bevor wir loslegen
Wir machen da weiter, wo Etappe 10 aufgehört hat. Den kompletten Stand findest du dort unter Zwischenstand.
Was ist SFTP?
SFTP steht für SSH File Transfer Protocol. Es läuft über SSH, die verschlüsselte Verbindung, mit der Administratoren sich auf Servern anmelden. Alles, was über die Leitung geht, ist verschlüsselt: Passwort, Dateinamen, Inhalte. Trotz des ähnlichen Namens ist SFTP kein „FTP mit Schloss“, sondern eine ganz andere Sprache. Darum brauchen wir eine andere Crate, und darum lohnt es sich, im Programm eine gemeinsame Schnittstelle für beide Sprachen zu bauen.
SSH hat noch eine zweite Schutzschicht: Jeder Server hat einen Schlüssel, an dem man ihn erkennt, wie an einem Fingerabdruck. Damit kann ein Programm prüfen, ob es wirklich mit dem richtigen Server spricht und nicht mit einem Betrüger dazwischen. Wie wir damit umgehen, kommt in Schritt 4.
Dein Rechner als SFTP-Server
Für SFTP brauchst du einen SSH-Server. Den hast du vielleicht schon, und wenn nicht, ist er schnell eingeschaltet. Dein eigener Rechner wird dabei zum Server, und du meldest dich mit deinem ganz normalen Benutzernamen und Passwort an.
- Linux (Fedora):
sudo dnf install openssh-serverundsudo systemctl start sshd. Auf Debian und Ubuntu heißt essudo apt install openssh-server, der Dienst heißt dortssh. - macOS: Systemeinstellungen → Allgemein → Teilen → Entfernte Anmeldung einschalten.
- Windows: Einstellungen → System → Optionale Features → OpenSSH-Server hinzufügen, dann den Dienst „OpenSSH SSH Server“ starten.
Ob es klappt, prüfst du im Terminal mit dem SFTP-Programm, das jedes dieser Systeme mitbringt:
sftp localhost
sftp localhost
Beim ersten Mal fragt sftp, ob du dem Fingerabdruck des Servers vertraust.
Antworte mit yes. Dann gibst du dein Passwort ein und landest bei der
Eingabeaufforderung sftp>. Mit ls siehst du deinen
Home-Ordner, mit exit verabschiedest du dich. Dein SFTP-Server läuft.sudo systemctl stop sshd.Umbenennen mit rust-analyzer
Bisher heißt alles, was mit dem Server zu tun hat, irgendwie „ftp“: der Typ
Ftp, das Feld ftp im Panel, die Funktion ftp_lesen.
Ab heute stimmt das nicht mehr, denn hinter einem Server kann auch SFTP stecken. Bevor wir
weiterbauen, benennen wir deshalb ein paar Dinge um. Von Hand wäre das mühsam und
fehleranfällig: Allein ftp taucht Dutzende Male auf. Zum Glück kann das dein
Editor.
Mit rust-analyzer in VS Code geht es so: Klick mit der rechten Maustaste auf einen Namen und wähl Symbol umbenennen (oder drück F2). Tipp den neuen Namen ein und bestätige mit Enter. rust-analyzer ändert ihn überall, wo genau dieses Ding gemeint ist, und nirgends sonst. Benenne so um:
| Wo du klickst | Alter Name | Neuer Name |
|---|---|---|
bei struct Ftp | Ftp | Server |
im struct Panel | ftp | server |
im struct RechteDialog | ftp | server |
bei async fn ftp_lesen | ftp_lesen | server_lesen |
bei async fn ftp_rechte_setzen | ftp_rechte_setzen | server_rechte_setzen |
im enum Message | FtpGelesen | Gelesen |
bei fn ftp_gelesen | ftp_gelesen | gelesen |
Die Funktionen ftp_verbinden, ftp_eintraege und
ftp_rechte behalten ihren Namen, denn die sprechen wirklich nur FTP. Auch
Variablen, die ftp heißen, zum Beispiel in Some(ftp) => …, darfst
du lassen. Und zum Schluss passt du von Hand den Kommentar über struct Server an:
aus „Eine offene FTP-Verbindung“ wird „Eine offene Verbindung zu einem Server“.
Ftp nur als ganzes Wort (sonst wird aus
AsyncFtpStream ein AsyncServerStream), und bei ftp nur
dort, wo das Feld gemeint ist, also bei .ftp und bei den Zeilen
ftp: … in den Structs. Im Zweifel hilft der
Zwischenstand.cargo run
Alles funktioniert genau wie vorher, und im Terminal steht keine Warnung. Ein
Umbenennen ist das kleinste Refactoring, das es gibt, und das sicherste, wenn das Werkzeug es
erledigt.Eine Leitung, viele Sprachen
Jetzt die wichtigste Idee dieser Etappe. Bisher steckt im Server direkt eine
FTP-Verbindung, und überall im Code wird FTP gesprochen: mkdir,
put_file, SITE CHMOD. Für SFTP heißen diese Befehle ganz anders.
Statt also überall „Ist das FTP oder SFTP?“ zu fragen, bauen wir eine
Leitung mit fünf Fähigkeiten: Ordner lesen, Ordner anlegen, Datei hoch,
Datei runter, Rechte setzen. Wie das in der jeweiligen Sprache geht, weiß nur die Leitung
selbst. Der Rest des Programms spricht nur noch mit der Leitung.
Zuerst bekommt der Server statt der FTP-Verbindung eine Leitung:
struct Server {strom: Arc<Mutex<AsyncFtpStream>>,strom: Arc<Mutex<Strom>>,name: String, // zum Beispiel "fahrgast@localhost"
Die Leitung ist ein enum, und ihre Fähigkeiten sind Funktionen in
impl Strom. Alles kommt hinter server_lesen:
liste_fertigmachen(&mut eintraege, pfad.parent().is_some()); Ok((pfad, eintraege)) }// Eine Leitung zum Server. Jede Art von Server spricht ihre eigene Sprache. enum Strom { Ftp(AsyncFtpStream), } // Was man mit jeder Leitung tun kann, egal welche Sprache der Server spricht. impl Strom { // Liest einen Ordner (ohne ".."). async fn eintraege(&mut self, pfad: &str) -> Result<Vec<Eintrag>, String> { match self { Strom::Ftp(ftp) => ftp_eintraege(ftp, pfad).await, } } // Legt einen Ordner an. async fn ordner_anlegen(&mut self, pfad: &str) -> Result<(), String> { match self { Strom::Ftp(ftp) => ftp.mkdir(pfad).await.map_err(als_text), } } // Schickt eine Datei von diesem Rechner zum Server. async fn datei_hoch(&mut self, von: &Path, nach: &str) -> Result<(), String> { let mut datei = tokio::fs::File::open(von).await.map_err(als_text)?; match self { Strom::Ftp(ftp) => { ftp.put_file(nach, &mut datei).await.map_err(als_text)?; } } Ok(()) } // Holt eine Datei vom Server auf diesen Rechner. async fn datei_runter(&mut self, von: &str, nach: &Path) -> Result<(), String> { let mut datei = tokio::fs::File::create(nach).await.map_err(als_text)?; match self { Strom::Ftp(ftp) => { let mut quelle = ftp.retr_as_stream(von).await.map_err(als_text)?; tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?; quelle.finish().await.map_err(als_text)?; } } Ok(()) } // Setzt die Rechte. FTP braucht dafür SITE CHMOD, das nicht jeder Server kann. async fn rechte_setzen(&mut self, pfad: &str, rechte: u32) -> Result<(), String> { match self { Strom::Ftp(ftp) => { let befehl = format!("CHMOD {:03o} {}", rechte, pfad); ftp.site(befehl).await.map(|_| ()).map_err(als_text) } } } }
Im Moment hat das enum nur eine Variante, Ftp. Das wirkt
übertrieben, aber im nächsten Schritt kommt Sftp dazu, und dann hat jede
Funktion zwei Zweige. Der Inhalt der Zweige ist dir vertraut: Es ist genau der FTP-Code, der
bisher in hoch und runter stand. Er zieht nur um.
Wo eine FTP-Verbindung gebaut wird, packen wir sie jetzt in die Leitung, und
server_lesen fragt die Leitung statt FTP direkt:
let ftp = Server {strom: Arc::new(Mutex::new(strom)),strom: Arc::new(Mutex::new(Strom::Ftp(strom))),⋮let mut strom = ftp.strom.lock().await;let mut eintraege = ftp_eintraege(&mut strom, &pfad_text(&pfad)).await?;let mut eintraege = strom.eintraege(&pfad_text(&pfad)).await?;
In hoch und runter ersetzen wir die FTP-Befehle durch die
Fähigkeiten der Leitung. Schau, wie viel kürzer und lesbarer der Code dadurch wird:
fn hoch<'a>(strom: &'a mut AsyncFtpStream,strom: &'a mut Strom,von: &'a Path,⋮if info.is_dir() {strom.mkdir(nach).await.map_err(als_text)?;strom.ordner_anlegen(nach).await?;⋮} else {let mut datei = tokio::fs::File::open(von).await.map_err(als_text)?; strom.put_file(nach, &mut datei).await.map_err(als_text)?;strom.datei_hoch(von, nach).await?;bilanz.dateien += 1;⋮let befehl = format!("CHMOD {:03o} {}", rechte_lesen(&info), nach); if strom.site(befehl).await.is_err() {if strom.rechte_setzen(nach, rechte_lesen(&info)).await.is_err() {
fn runter<'a>(strom: &'a mut AsyncFtpStream,strom: &'a mut Strom,von: &'a str,⋮for eintrag in ftp_eintraege(strom, von).await? {for eintrag in strom.eintraege(von).await? {⋮} else {let mut quelle = strom.retr_as_stream(von).await.map_err(als_text)?; let mut datei = tokio::fs::File::create(nach).await.map_err(als_text)?; tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?; quelle.finish().await.map_err(als_text)?;strom.datei_runter(von, nach).await?;bilanz.dateien += 1;
Und genauso beim Rechteändern:
let befehl = format!("CHMOD {:03o} {}", rechte, pfad_text(&pfad)); match strom.site(befehl).await { Ok(_) => Ok(format!(match strom.rechte_setzen(&pfad_text(&pfad), rechte).await { Ok(()) => Ok(format!(
enum ist aber einfacher, wenn man genau
weiß, welche Varianten es gibt, und es hat einen großen Vorteil: Kommt eine neue Variante
dazu, zeigt der Compiler jede Stelle, an der sie noch fehlt. Das erleben wir gleich.cargo run
Verbinde dich mit deinem FTP-Testserver aus Etappe 8 und probier Hochladen,
Herunterladen und Rechte: Alles funktioniert wie vorher, nur steckt der FTP-Code jetzt an
einem einzigen Ort.Die Leitung lernt SFTP
Für SSH und SFTP holen wir zwei Crates an Bord: russh für die verschlüsselte SSH-Verbindung und russh-sftp für die SFTP-Sprache darüber.
cargo add russh@0.63 --no-default-features --features ring,flate2,rsa
cargo add russh-sftp@3
Bei russh schalten wir die Standard-Features ab und wählen sie selbst. Der Grund ist die
Verschlüsselung: Standardmäßig benutzt russh eine Bibliothek, die beim Übersetzen zusätzliche
Werkzeuge wie CMake braucht. Mit ring geht es auf allen Systemen ohne Extras.
flate2 erlaubt Kompression, rsa versteht ältere Server-Schlüssel.
Danach kommen zwei Zeilen in der Cargo.toml dazu:
russh = { version = "0.63", default-features = false, features = ["ring", "flate2", "rsa"] }
russh-sftp = "3"
Oben in der Datei holen wir die Werkzeuge herein. AsyncWriteExt brauchen wir für
shutdown, das eine Datei auf dem Server ordentlich abschließt:
use suppaftp::list::{File, PosixPexQuery};use russh::keys::{HashAlg, PublicKeyOrCertificate}; use russh_sftp::client::SftpSession; use russh_sftp::protocol::FileAttributes; use suppaftp::list::{File, PosixPexQuery};⋮use tokio::sync::Mutex;use tokio::io::AsyncWriteExt; use tokio::sync::Mutex;
Jetzt bekommt die Leitung ihre zweite Variante. Eine SFTP-Leitung besteht aus zwei Teilen:
der SFTP-Sitzung, mit der wir arbeiten, und der SSH-Verbindung darunter, die am Leben bleiben
muss. Der Unterstrich vor _sitzung sagt Rust: „Ich benutze das nicht direkt, ich
will es nur behalten.“
enum Strom { Ftp(AsyncFtpStream),Sftp { sftp: SftpSession, _sitzung: russh::client::Handle<Pruefer>, // hält die SSH-Verbindung am Leben },}
Sofort meldet Rust Fehler: In allen fünf Funktionen von impl Strom fehlt der
Zweig für Sftp. Genau das versprach der Kasten im letzten Schritt. Also bekommt
jede Funktion ihren SFTP-Zweig, jeweils direkt unter dem FTP-Zweig:
Strom::Ftp(ftp) => ftp_eintraege(ftp, pfad).await,Strom::Sftp { sftp, .. } => { let mut eintraege = Vec::new(); for eintrag in sftp.read_dir(pfad).await.map_err(als_text)? { let name = eintrag.file_name(); if name.starts_with('.') { continue; } let info = eintrag.metadata(); eintraege.push(Eintrag { name, ist_ordner: eintrag.file_type().is_dir(), groesse: info.size.unwrap_or(0), rechte: info.permissions.unwrap_or(0) & 0o777, }); } Ok(eintraege) }}⋮Strom::Ftp(ftp) => ftp.mkdir(pfad).await.map_err(als_text),Strom::Sftp { sftp, .. } => sftp.create_dir(pfad).await.map_err(als_text),}⋮ftp.put_file(nach, &mut datei).await.map_err(als_text)?; }Strom::Sftp { sftp, .. } => { let mut ziel = sftp.create(nach).await.map_err(als_text)?; tokio::io::copy(&mut datei, &mut ziel).await.map_err(als_text)?; ziel.shutdown().await.map_err(als_text)?; }}⋮quelle.finish().await.map_err(als_text)?; }Strom::Sftp { sftp, .. } => { let mut quelle = sftp.open(von).await.map_err(als_text)?; tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?; }}⋮ftp.site(befehl).await.map(|_| ()).map_err(als_text) }Strom::Sftp { sftp, .. } => { let mut attribute = FileAttributes::empty(); attribute.permissions = Some(rechte); sftp.set_metadata(pfad, attribute).await.map_err(als_text) }}
- Ordner lesen: Bei SFTP bekommen wir keine Textzeilen, sondern fertige
Einträge mit Name, Art, Größe und Rechten. Wie bei Etappe 7 behalten wir mit
& 0o777nur die neun Rechte-Bits. - Datei hoch und runter: SFTP-Dateien lassen sich wie lokale Dateien lesen
und schreiben, darum genügt
tokio::io::copy. Beim Hochladen schließtshutdowndie Datei auf dem Server ab. - Rechte: SFTP kann Rechte immer setzen, ganz ohne
SITE CHMOD. Wir bauen leere Datei-Attribute, tragen nur die Rechte ein und schicken sie mitset_metadata.
Strom::Sftp { sftp, .. } packt aus der Variante nur das Feld
sftp aus, die zwei Punkte ignorieren den Rest. Bleibt noch der
Pruefer, der in der Variante steht. Er kommt direkt vor enum Strom:
// Beantwortet beim SSH-Verbindungsaufbau die Frage: "Ist das wirklich der richtige Server?" // Wir merken uns dabei den Fingerabdruck des Servers, um ihn anzuzeigen. struct Pruefer { fingerabdruck: Arc<std::sync::Mutex<String>>, } impl russh::client::Handler for Pruefer { type Error = russh::Error; async fn check_server_key( &mut self, schluessel: &PublicKeyOrCertificate, ) -> Result<bool, Self::Error> { if let PublicKeyOrCertificate::PublicKey { key, .. } = schluessel { *self.fingerabdruck.lock().unwrap() = key.fingerprint(HashAlg::Sha256).to_string(); } Ok(true) // Wir vertrauen jedem Server. Mehr dazu im Kasten in Schritt 5! } }// Eine Leitung zum Server. Jede Art von Server spricht ihre eigene Sprache.
russh fragt beim Verbinden einen sogenannten Handler, was er von bestimmten
Dingen hält, zum Beispiel vom Schlüssel des Servers. Den Handler schreiben wir, indem wir den
Trait russh::client::Handler für unser eigenes struct umsetzen.
check_server_key bekommt den Schlüssel, wir merken uns seinen Fingerabdruck in
einem Arc<Mutex<String>> und antworten mit Ok(true):
„Passt.“ Hier ist es der Mutex aus der Standardbibliothek, weil nicht auf ihn
gewartet werden muss. Darum steht der volle Pfad std::sync::Mutex da, denn der
kurze Name Mutex gehört in unserer Datei schon dem von tokio.
cargo run
Der erste Start dauert wieder eine Weile, denn die neuen Crates werden übersetzt.
FTP funktioniert wie vorher. Rust warnt, dass die Variante Sftp nie gebaut wird.
Das ändern wir im nächsten Schritt.Per SSH verbinden
Den Fingerabdruck wollen wir nach dem Verbinden anzeigen, also merkt sich der
Server ihn. Bei FTP gibt es keinen, darum eine Option:
name: String, // zum Beispiel "fahrgast@localhost"fingerabdruck: Option<String>, // nur bei SFTP}
strom: Arc::new(Mutex::new(Strom::Ftp(strom))), name: format!("{}@{}", zugang.benutzer, zugang.host),fingerabdruck: None,};
Und jetzt das Gegenstück zu ftp_verbinden, direkt dahinter:
Ok((ftp, PathBuf::from(start), eintraege)) }// Verbindet sich per SSH, meldet sich an, öffnet SFTP und liest den Startordner. async fn sftp_verbinden(zugang: Zugang) -> Result<(Server, PathBuf, Vec<Eintrag>), String> { let port: u16 = zugang .port .trim() .parse() .map_err(|_| format!("„{}“ ist kein gültiger Port.", zugang.port))?; let fingerabdruck = Arc::new(std::sync::Mutex::new(String::new())); let pruefer = Pruefer { fingerabdruck: fingerabdruck.clone(), }; let einstellungen = Arc::new(russh::client::Config::default()); let mut sitzung = russh::client::connect(einstellungen, (zugang.host.trim(), port), pruefer) .await .map_err(als_text)?; let anmeldung = sitzung .authenticate_password(&zugang.benutzer, &zugang.passwort) .await .map_err(als_text)?; if !anmeldung.success() { return Err(String::from("Benutzername oder Passwort stimmt nicht.")); } let kanal = sitzung.channel_open_session().await.map_err(als_text)?; kanal.request_subsystem(true, "sftp").await.map_err(als_text)?; let sftp = SftpSession::new(kanal.into_stream()).await.map_err(als_text)?; let start = sftp.canonicalize(".").await.map_err(als_text)?; let mut strom = Strom::Sftp { sftp, _sitzung: sitzung, }; let mut eintraege = strom.eintraege(&start).await?; liste_fertigmachen(&mut eintraege, start != "/"); let server = Server { strom: Arc::new(Mutex::new(strom)), name: format!("{}@{}", zugang.benutzer, zugang.host), fingerabdruck: Some(fingerabdruck.lock().unwrap().clone()), }; Ok((server, PathBuf::from(start), eintraege)) }
- Port prüfen: russh will den Port als Zahl.
parse()versucht die Umwandlung, und wenn jemand „22a“ eingetippt hat, gibt es eine verständliche Meldung statt eines Absturzes. Das: u16sagtparse, welche Art Zahl herauskommen soll. Ports gehen bis 65535, genau der Bereich vonu16. - Verbinden:
russh::client::connectbaut die verschlüsselte Verbindung auf und fragt dabei unserenPruefernach dem Schlüssel. Weil wir ihm einen Klon desArcmitgeben, können wir den Fingerabdruck danach selbst lesen. - Anmelden:
authenticate_passwordschickt Name und Passwort, diesmal verschlüsselt. Ein falsches Passwort ist bei SSH kein Fehler im Sinne vonErr, sondern eine Antwort „hat nicht geklappt“. Darum fragen wirsuccess()ab. - SFTP öffnen: Über die SSH-Verbindung öffnen wir einen
Kanal und bitten den Server, darin SFTP zu sprechen. Dann wird aus dem Kanal
eine
SftpSession.canonicalize(".")fragt, wo wir gelandet sind, meist im Home-Ordner. - Einpacken: Wie bei FTP lesen wir den Startordner und packen alles in
einen
Server.
ssh merken sich deshalb den
Fingerabdruck beim ersten Besuch in der Datei ~/.ssh/known_hosts und schlagen
Alarm, wenn er sich später ändert. Der Fährmann zeigt den Fingerabdruck wenigstens an, sodass
du ihn mit dem vergleichen kannst, den sftp dir in Schritt 1 genannt hat. Wie man
das richtig absichert, ist eine der Übungen.cargo run
Rust warnt jetzt, dass sftp_verbinden nie aufgerufen wird. Der
Fährmann kann SFTP, er weiß nur noch nicht, wann er es benutzen soll.FTP oder SFTP? Der Dialog fragt
Zum Schluss soll man im Verbindungsdialog wählen können, welche Sprache gesprochen wird. Die Zugangsdaten merken sich das:
struct Zugang {sftp: bool, // true: SFTP, false: FTPhost: String,
Umgeschaltet wird mit F3. Eine Briefsorte und eine Taste:
Verbinden,ProtokollWechseln,
Key::Named(Named::F2) => Some(Message::VerbindenZeigen),Key::Named(Named::F3) => Some(Message::ProtokollWechseln),
Im Verbindungsdialog schaltet der Brief um und füllt passende Vorschläge ein: SSH-Server
lauschen meist auf Port 22, und angemeldet wird man mit dem eigenen Benutzernamen. Den liest
std::env::var aus der Umgebung des Betriebssystems, unter Linux und macOS als
USER, unter Windows als USERNAME. Das Passwort leeren wir, denn das
vom FTP-Testserver passt hier nicht. Außerhalb des Dialogs hat der Brief keine Bedeutung:
Message::Eingabe(Feld::Passwort, wert) => zugang.passwort = wert,Message::ProtokollWechseln => { zugang.sftp = !zugang.sftp; if zugang.sftp { zugang.port = String::from("22"); zugang.benutzer = std::env::var("USER") .or_else(|_| std::env::var("USERNAME")) .unwrap_or_default(); zugang.passwort = String::new(); } else { zugang.port = String::from("2121"); } }Message::Wechseln => return operation::focus_next(),⋮| Message::Eingabe(..)| Message::Verbinden => {} // ohne Dialog ohne Bedeutung| Message::Verbinden | Message::ProtokollWechseln => {} // ohne Dialog ohne Bedeutung
Ein neuer Dialog startet mit FTP:
self.verbindungsdialog = Some(Zugang {sftp: false,host: String::from("localhost"),
Beim Verbinden entscheidet zugang.sftp, welche Funktion im Hintergrund läuft.
Beide liefern dasselbe Ergebnis, darum landen beide im selben Verbunden-Brief:
self.meldung = format!("Verbinde mit {}:{} …", zugang.host, zugang.port);Task::perform(ftp_verbinden(zugang), Message::Verbunden)if zugang.sftp { Task::perform(sftp_verbinden(zugang), Message::Verbunden) } else { Task::perform(ftp_verbinden(zugang), Message::Verbunden) }}
Nach dem Verbinden zeigt die Meldungszeile bei SFTP den Fingerabdruck:
Ok((ftp, ordner, eintraege)) => {self.meldung = format!("Verbunden mit {}. F2 trennt die Verbindung.", ftp.name);self.meldung = match &ftp.fingerabdruck { Some(abdruck) => format!("Verbunden mit {} per SFTP. Schlüssel: {}", ftp.name, abdruck), None => format!("Verbunden mit {}. F2 trennt die Verbindung.", ftp.name), };
Und der Dialog sagt oben, welches Protokoll eingestellt ist:
text("Mit einem Server verbinden"),text(if zugang.sftp { "Protokoll: SFTP (verschlüsselt) · F3 wechselt" } else { "Protokoll: FTP (unverschlüsselt) · F3 wechselt" }),
cargo run
Drück F2 und dann F3: Oben steht jetzt SFTP, der Port ist 22
und dein Benutzername ist eingetragen. Tipp dein Passwort ins letzte Feld und drück
Enter. Rechts erscheint dein Home-Ordner, diesmal verschlüsselt geholt, und unten
steht der Fingerabdruck. Vergleich ihn mit dem aus Schritt 1. Jetzt probier alles, was der
Fährmann kann: Ordner hoch- und herunterladen, Rechte mit F9 ändern. Es
funktioniert genauso wie bei FTP, weil all das nur mit der Leitung spricht.
Der komplette Code nach Etappe 11
Hier ist der vollständige Stand nach Etappe 11. Nimm ihn zum Vergleichen, wenn irgendwo ein Fehler steckt, den du nicht findest, oder als Einstiegspunkt, wenn du erst bei der nächsten Etappe dazustößt.
Cargo.toml und src/main.rs anzeigen
Cargo.toml
[package]
name = "faehrmann"
version = "0.1.0"
edition = "2024"
[dependencies]
iced = { version = "0.14", features = ["tokio"] }
suppaftp = { version = "12", features = ["tokio"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
russh = { version = "0.63", default-features = false, features = ["ring", "flate2", "rsa"] }
russh-sftp = "3"
src/main.rs
use std::fmt;
use std::fs;
use std::future::Future;
use std::io;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use russh::keys::{HashAlg, PublicKeyOrCertificate};
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::FileAttributes;
use suppaftp::list::{File, PosixPexQuery};
use suppaftp::tokio::AsyncFtpStream;
use suppaftp::types::FileType;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use iced::widget::{column, container, operation, row, stack, text, text_input};
use iced::keyboard::{self, Key, key::Named};
use iced::{
Background, Border, Center, Color, Element, Fill, Font, Right, Subscription, Task, Theme,
};
// Die Farben des Norton Commander
const BLAU: Color = Color::from_rgb8(0, 0, 170);
const TUERKIS: Color = Color::from_rgb8(0, 170, 170);
const SCHWARZ: Color = Color::BLACK;
const WEISS: Color = Color::WHITE;
const HELLTUERKIS: Color = Color::from_rgb8(85, 255, 255);
const GELB: Color = Color::from_rgb8(255, 255, 85);
const GRAU: Color = Color::from_rgb8(170, 170, 170);
// So viele Einträge passen untereinander in ein Panel
const ZEILEN: usize = 24;
// Ein Eintrag in einem Ordner: eine Datei oder ein Unterordner.
#[derive(Debug, Clone)]
struct Eintrag {
name: String,
ist_ordner: bool,
groesse: u64,
rechte: u32, // Zugriffsrechte, zum Beispiel 0o644
}
fn main() -> iced::Result {
iced::application(Faehrmann::neu, Faehrmann::update, Faehrmann::view)
.subscription(Faehrmann::subscription)
.title("Fährmann")
.default_font(Font::MONOSPACE)
.window_size((1000.0, 640.0))
.run()
}
// Das Gedächtnis des Fährmanns: welcher Ordner offen ist und was darin liegt.
struct Faehrmann {
links: Panel,
rechts: Panel,
aktiv: Seite,
meldung: String, // was der Fährmann zuletzt zu sagen hatte
dialog: Option<RechteDialog>, // offener Dialog, falls es einen gibt
verbindungsdialog: Option<Zugang>, // offener Verbindungsdialog, falls es einen gibt
}
// Welches der beiden Panels ist gerade aktiv?
#[derive(PartialEq)]
enum Seite {
Links,
Rechts,
}
// Der Dialog zum Ändern der Rechte: für welche Datei, welche Rechte, welcher Schalter gewählt ist.
struct RechteDialog {
name: String,
pfad: PathBuf,
rechte: u32,
auswahl: usize, // 0 bis 8, welcher der neun Schalter gerade markiert ist
server: Option<Server>, // Some, wenn die Datei auf dem Server liegt
}
// Die Zugangsdaten für einen Server, so wie sie im Verbindungsdialog stehen.
#[derive(Debug, Clone)]
struct Zugang {
sftp: bool, // true: SFTP, false: FTP
host: String,
port: String,
benutzer: String,
passwort: String,
}
// Welches der vier Eingabefelder ist gemeint?
#[derive(Debug, Clone, Copy)]
enum Feld {
Host,
Port,
Benutzer,
Passwort,
}
// Eine offene Verbindung zu einem Server. Arc und Mutex erlauben, dass mehrere Aufgaben im
// Hintergrund sie benutzen, ohne sich gegenseitig in die Quere zu kommen.
#[derive(Clone)]
struct Server {
strom: Arc<Mutex<Strom>>,
name: String, // zum Beispiel "fahrgast@localhost"
fingerabdruck: Option<String>, // nur bei SFTP
}
// Nachrichten müssen sich mit {:?} ausgeben lassen. Die Verbindung selbst kann das
// nicht, also schreiben wir es für Server von Hand.
impl fmt::Debug for Server {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Server({})", self.name)
}
}
#[derive(Debug, Clone)]
enum Message {
Hoch,
Runter,
Oeffnen,
Wechseln,
Kopieren,
RechteZeigen,
PfeilLinks,
PfeilRechts,
Umschalten,
Abbrechen,
VerbindenZeigen,
Eingabe(Feld, String),
Verbinden,
ProtokollWechseln,
Verbunden(Result<(Server, PathBuf, Vec<Eintrag>), String>),
Gelesen(Result<(PathBuf, Vec<Eintrag>), String>),
Fertig(Result<String, String>),
Beenden,
}
impl Faehrmann {
// Startet im Ordner, aus dem das Programm aufgerufen wurde.
fn neu() -> Self {
let ordner = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
Faehrmann {
links: Panel::neu(ordner.clone()),
rechts: Panel::neu(ordner),
aktiv: Seite::Links,
meldung: String::from("Willkommen an Bord! Tab wechselt das Panel, F5 kopiert, F10 beendet."),
dialog: None,
verbindungsdialog: None,
}
}
fn update(&mut self, message: Message) -> Task<Message> {
// Antworten vom Server kommen immer an, egal ob gerade ein Dialog offen ist
let message = match message {
Message::Verbunden(ergebnis) => return self.verbunden(ergebnis),
Message::Gelesen(ergebnis) => return self.gelesen(ergebnis),
Message::Fertig(ergebnis) => return self.fertig(ergebnis),
andere => andere,
};
// Ist der Verbindungsdialog offen, kümmert er sich um die Tasten
if let Some(zugang) = &mut self.verbindungsdialog {
match message {
Message::Eingabe(Feld::Host, wert) => zugang.host = wert,
Message::Eingabe(Feld::Port, wert) => zugang.port = wert,
Message::Eingabe(Feld::Benutzer, wert) => zugang.benutzer = wert,
Message::Eingabe(Feld::Passwort, wert) => zugang.passwort = wert,
Message::ProtokollWechseln => {
zugang.sftp = !zugang.sftp;
if zugang.sftp {
zugang.port = String::from("22");
zugang.benutzer = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_default();
zugang.passwort = String::new();
} else {
zugang.port = String::from("2121");
}
}
Message::Wechseln => return operation::focus_next(),
Message::Verbinden => return self.verbinden(),
Message::Abbrechen => self.verbindungsdialog = None,
Message::Beenden => return iced::exit(),
_ => {}
}
return Task::none();
}
// Ist der Rechte-Dialog offen, gehen alle Tasten an ihn
if let Some(dialog) = &mut self.dialog {
match message {
Message::Hoch => dialog.auswahl = (dialog.auswahl + 6) % 9,
Message::Runter => dialog.auswahl = (dialog.auswahl + 3) % 9,
Message::PfeilLinks => dialog.auswahl = (dialog.auswahl + 8) % 9,
Message::PfeilRechts => dialog.auswahl = (dialog.auswahl + 1) % 9,
Message::Umschalten => dialog.rechte ^= bit(dialog.auswahl),
Message::Oeffnen => return self.rechte_anwenden(),
Message::Abbrechen => self.dialog = None,
Message::Beenden => return iced::exit(),
_ => {}
}
return Task::none();
}
match message {
Message::Hoch => self.aktives_panel().hoch(),
Message::Runter => self.aktives_panel().runter(),
Message::Oeffnen => return self.oeffnen(),
Message::Wechseln => {
self.aktiv = match self.aktiv {
Seite::Links => Seite::Rechts,
Seite::Rechts => Seite::Links,
};
}
Message::Kopieren => return self.kopieren(),
Message::RechteZeigen => self.rechte_zeigen(),
Message::VerbindenZeigen => return self.verbindung_zeigen(),
Message::PfeilLinks
| Message::PfeilRechts
| Message::Umschalten
| Message::Abbrechen
| Message::Eingabe(..)
| Message::Verbinden
| Message::ProtokollWechseln => {} // ohne Dialog ohne Bedeutung
Message::Verbunden(_) | Message::Gelesen(_) | Message::Fertig(_) => {} // oben erledigt
Message::Beenden => return iced::exit(),
}
Task::none()
}
// Abonniert die Tastatur: Jeder Tastendruck geht durch die Funktion taste.
fn subscription(&self) -> Subscription<Message> {
keyboard::listen().filter_map(taste)
}
// Liefert das Panel, das gerade aktiv ist, zum Verändern.
fn aktives_panel(&mut self) -> &mut Panel {
match self.aktiv {
Seite::Links => &mut self.links,
Seite::Rechts => &mut self.rechts,
}
}
// Liefert das Panel, das gerade NICHT aktiv ist, zum Verändern.
fn inaktives_panel(&mut self) -> &mut Panel {
match self.aktiv {
Seite::Links => &mut self.rechts,
Seite::Rechts => &mut self.links,
}
}
// Liefert beide Panels zum Anschauen: zuerst das aktive, dann das andere.
fn quelle_und_ziel(&self) -> (&Panel, &Panel) {
match self.aktiv {
Seite::Links => (&self.links, &self.rechts),
Seite::Rechts => (&self.rechts, &self.links),
}
}
// Öffnet den Verbindungsdialog, schon ausgefüllt für unseren Testserver.
fn verbindung_zeigen(&mut self) -> Task<Message> {
// Zeigt rechts schon ein Server, trennt F2 die Verbindung wieder
if let Some(ftp) = &self.rechts.server {
self.meldung = format!("Verbindung zu {} getrennt.", ftp.name);
let ordner = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
self.rechts = Panel::neu(ordner);
return Task::none();
}
self.verbindungsdialog = Some(Zugang {
sftp: false,
host: String::from("localhost"),
port: String::from("2121"),
benutzer: String::from("fahrgast"),
passwort: String::from("geheim"),
});
operation::focus("host")
}
// Baut die Verbindung im Hintergrund auf.
fn verbinden(&mut self) -> Task<Message> {
let Some(zugang) = self.verbindungsdialog.take() else {
return Task::none();
};
self.meldung = format!("Verbinde mit {}:{} …", zugang.host, zugang.port);
if zugang.sftp {
Task::perform(sftp_verbinden(zugang), Message::Verbunden)
} else {
Task::perform(ftp_verbinden(zugang), Message::Verbunden)
}
}
// Die Antwort auf den Verbindungsaufbau ist da.
fn verbunden(&mut self, ergebnis: Result<(Server, PathBuf, Vec<Eintrag>), String>) -> Task<Message> {
match ergebnis {
Ok((ftp, ordner, eintraege)) => {
self.meldung = match &ftp.fingerabdruck {
Some(abdruck) => format!("Verbunden mit {} per SFTP. Schlüssel: {}", ftp.name, abdruck),
None => format!("Verbunden mit {}. F2 trennt die Verbindung.", ftp.name),
};
self.rechts = Panel::entfernt(ftp, ordner, eintraege);
self.aktiv = Seite::Rechts;
}
Err(fehler) => self.meldung = format!("Verbindung fehlgeschlagen: {}", fehler),
}
Task::none()
}
// Ein Ordner vom Server ist gelesen. Server-Ordner zeigt immer das rechte Panel.
fn gelesen(&mut self, ergebnis: Result<(PathBuf, Vec<Eintrag>), String>) -> Task<Message> {
match ergebnis {
Ok((ordner, eintraege)) => self.rechts.eintraege_setzen(ordner, eintraege),
Err(fehler) => self.meldung = format!("Der Server sagt: {}", fehler),
}
Task::none()
}
// Eine Übertragung oder Änderung auf dem Server ist fertig.
fn fertig(&mut self, ergebnis: Result<String, String>) -> Task<Message> {
self.meldung = match ergebnis {
Ok(text) => text,
Err(fehler) => fehler,
};
self.links.neu_lesen();
self.rechts_neu_lesen()
}
// Liest das rechte Panel neu ein, vom Server im Hintergrund, falls es verbunden ist.
fn rechts_neu_lesen(&mut self) -> Task<Message> {
match &self.rechts.server {
Some(ftp) => Task::perform(
server_lesen(ftp.clone(), self.rechts.ordner.clone()),
Message::Gelesen,
),
None => {
self.rechts.neu_lesen();
Task::none()
}
}
}
// Enter: in den markierten Ordner gehen. Auf dem Server passiert das im Hintergrund.
fn oeffnen(&mut self) -> Task<Message> {
let panel = self.aktives_panel();
match &panel.server {
Some(ftp) => match panel.ziel_ordner() {
Some(ziel) => Task::perform(server_lesen(ftp.clone(), ziel), Message::Gelesen),
None => Task::none(),
},
None => {
panel.oeffnen();
Task::none()
}
}
}
// Öffnet den Rechte-Dialog für den markierten Eintrag.
fn rechte_zeigen(&mut self) {
let panel = self.aktives_panel();
let Some(eintrag) = panel.markierter_eintrag() else {
return;
};
if eintrag.name == ".." {
return;
}
let dialog = RechteDialog {
name: eintrag.name.clone(),
pfad: panel.unterpfad(&eintrag.name),
rechte: eintrag.rechte,
auswahl: 0,
server: panel.server.clone(),
};
self.dialog = Some(dialog);
}
// Setzt die im Dialog gewählten Rechte und schließt den Dialog.
fn rechte_anwenden(&mut self) -> Task<Message> {
let Some(dialog) = self.dialog.take() else {
return Task::none();
};
if let Some(ftp) = dialog.server {
self.meldung = format!("Setze die Rechte von „{}“ …", dialog.name);
let aufgabe = server_rechte_setzen(ftp, dialog.pfad, dialog.rechte, dialog.name);
return Task::perform(aufgabe, Message::Fertig);
}
self.meldung = match rechte_setzen(&dialog.pfad, dialog.rechte) {
Ok(()) => format!(
"Rechte von „{}“ sind jetzt {} ({:03o}).",
dialog.name,
rechte_text(dialog.rechte),
dialog.rechte
),
Err(fehler) => format!("Rechte ändern fehlgeschlagen: {}", fehler),
};
self.links.neu_lesen();
self.rechts.neu_lesen();
Task::none()
}
// Kopiert die markierte Datei vom aktiven Panel in den Ordner des anderen Panels.
fn kopieren(&mut self) -> Task<Message> {
let (quelle, ziel) = self.quelle_und_ziel();
let Some(eintrag) = quelle.markierter_eintrag() else {
return Task::none();
};
if eintrag.name == ".." {
return Task::none();
}
// Alles, was wir brauchen, herausschreiben, solange wir die Panels ausleihen
let name = eintrag.name.clone();
let ist_ordner = eintrag.ist_ordner;
let rechte = eintrag.rechte;
let von = quelle.unterpfad(&name);
let nach = ziel.unterpfad(&name);
let zielordner = ziel.ordner.display().to_string();
let schon_da = ziel.eintraege.iter().any(|anderer| anderer.name == name);
let server_ziel = ziel.server.clone();
let server_quelle = quelle.server.clone();
if schon_da {
self.meldung = format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner);
return Task::none();
}
if let Some(ftp) = server_ziel {
self.meldung = format!("Lade „{}“ hoch …", name);
return Task::perform(hochladen(ftp, von, nach, name), Message::Fertig);
}
if let Some(ftp) = server_quelle {
self.meldung = format!("Lade „{}“ herunter …", name);
let aufgabe = herunterladen(ftp, von, nach, ist_ordner, rechte, name);
return Task::perform(aufgabe, Message::Fertig);
}
// Beide Panels auf diesem Rechner: kopieren wie bisher
self.meldung = if nach.exists() {
format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner)
} else if ist_ordner && nach.starts_with(&von) {
format!("„{}“ kann nicht in sich selbst kopiert werden.", name)
} else if ist_ordner {
match ordner_kopieren(&von, &nach) {
Ok(anzahl) => format!(
"Ordner kopiert: {} mit {} Dateien → {}",
name, anzahl, zielordner
),
Err(fehler) => format!("Ordner kopieren fehlgeschlagen: {}", fehler),
}
} else {
match fs::copy(&von, &nach) {
Ok(_) => format!("Kopiert: {} → {}", name, zielordner),
Err(fehler) => format!("Kopieren fehlgeschlagen: {}", fehler),
}
};
self.inaktives_panel().neu_lesen();
Task::none()
}
fn view(&self) -> Element<'_, Message> {
let menuzeile = container(text(" Links Datei Befehle Optionen Rechts"))
.width(Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(TUERKIS)),
text_color: Some(SCHWARZ),
..container::Style::default()
});
let panels = row![
self.links.ansicht(self.aktiv == Seite::Links),
self.rechts.ansicht(self.aktiv == Seite::Rechts),
]
.spacing(6);
let arbeitsflaeche = container(panels)
.width(Fill)
.height(Fill)
.padding(6)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(BLAU)),
text_color: Some(WEISS),
..container::Style::default()
});
let meldungszeile = container(text(self.meldung.as_str()).color(WEISS))
.width(Fill)
.padding([0, 6])
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(SCHWARZ)),
..container::Style::default()
});
let tastenleiste = row![
f_taste(1, "Hilfe"),
f_taste(2, "Verbind."),
f_taste(3, "Ansicht"),
f_taste(4, "Bearb."),
f_taste(5, "Kopie"),
f_taste(6, "Versch."),
f_taste(7, "Ordner"),
f_taste(8, "Lösch."),
f_taste(9, "Rechte"),
f_taste(10, "Ende"),
]
.spacing(4);
let tastenleiste = container(tastenleiste)
.width(Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(SCHWARZ)),
..container::Style::default()
});
let hauptteil = column![menuzeile, arbeitsflaeche, meldungszeile, tastenleiste];
// Ist ein Dialog offen, liegt er mittig über allem anderen
if let Some(zugang) = &self.verbindungsdialog {
stack![hauptteil, container(verbindung_ansicht(zugang)).center(Fill)].into()
} else if let Some(dialog) = &self.dialog {
stack![hauptteil, container(dialog_ansicht(dialog)).center(Fill)].into()
} else {
hauptteil.into()
}
}
}
// Ein Panel: ein Ordner, seine Einträge und der Auswahlbalken.
struct Panel {
ordner: PathBuf,
eintraege: Vec<Eintrag>,
cursor: usize, // Nummer des markierten Eintrags
erste_zeile: usize, // Nummer des obersten sichtbaren Eintrags
server: Option<Server>, // Some, wenn das Panel einen Server zeigt
}
impl Panel {
// Legt ein Panel an, das den angegebenen Ordner zeigt.
fn neu(ordner: PathBuf) -> Self {
let eintraege = ordner_lesen(&ordner);
Panel {
ordner,
eintraege,
cursor: 0,
erste_zeile: 0,
server: None,
}
}
// Legt ein Panel an, das einen Ordner auf dem Server zeigt.
fn entfernt(ftp: Server, ordner: PathBuf, eintraege: Vec<Eintrag>) -> Self {
Panel {
ordner,
eintraege,
cursor: 0,
erste_zeile: 0,
server: Some(ftp),
}
}
// Balken eine Zeile nach oben
fn hoch(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
self.sichtbar_halten();
}
// Balken eine Zeile nach unten
fn runter(&mut self) {
if self.cursor + 1 < self.eintraege.len() {
self.cursor += 1;
}
self.sichtbar_halten();
}
// Liefert den Eintrag, auf dem der Balken steht, falls es einen gibt.
fn markierter_eintrag(&self) -> Option<&Eintrag> {
self.eintraege.get(self.cursor)
}
// Liest den Ordner neu ein, zum Beispiel nachdem eine Datei dazugekommen ist.
fn neu_lesen(&mut self) {
if self.server.is_some() {
return; // Server-Ordner liest der Fährmann im Hintergrund, nicht hier
}
self.eintraege = ordner_lesen(&self.ordner);
if self.cursor >= self.eintraege.len() {
self.cursor = self.eintraege.len().saturating_sub(1);
}
self.sichtbar_halten();
}
// Schiebt den sichtbaren Ausschnitt so, dass der Balken immer zu sehen ist.
fn sichtbar_halten(&mut self) {
if self.cursor < self.erste_zeile {
self.erste_zeile = self.cursor;
}
if self.cursor >= self.erste_zeile + ZEILEN {
self.erste_zeile = self.cursor + 1 - ZEILEN;
}
}
// Betritt den markierten Ordner, oder geht bei ".." eine Ebene hoch.
// (Nur für Ordner auf diesem Rechner. Server-Ordner liest der Fährmann im Hintergrund.)
fn oeffnen(&mut self) {
let Some(ziel) = self.ziel_ordner() else {
return;
};
let eintraege = ordner_lesen(&ziel);
self.eintraege_setzen(ziel, eintraege);
}
// Wohin führt Enter? Liefert den Ordner, falls der Balken auf einem Ordner steht.
fn ziel_ordner(&self) -> Option<PathBuf> {
let eintrag = self.markierter_eintrag()?;
if !eintrag.ist_ordner {
return None; // Dateien öffnen wir (noch) nicht
}
if eintrag.name == ".." {
self.ordner.parent().map(|oben| oben.to_path_buf())
} else {
Some(self.unterpfad(&eintrag.name))
}
}
// Der Pfad eines Eintrags in diesem Panel. Auf dem Server immer mit "/".
fn unterpfad(&self, name: &str) -> PathBuf {
if self.server.is_some() {
let ordner = pfad_text(&self.ordner);
PathBuf::from(format!("{}/{}", ordner.trim_end_matches('/'), name))
} else {
self.ordner.join(name)
}
}
// Zeigt neue Einträge an. Bleibt der Ordner derselbe, bleibt auch der Balken stehen.
fn eintraege_setzen(&mut self, ordner: PathBuf, eintraege: Vec<Eintrag>) {
if ordner != self.ordner {
self.cursor = 0;
self.erste_zeile = 0;
}
self.ordner = ordner;
self.eintraege = eintraege;
if self.cursor >= self.eintraege.len() {
self.cursor = self.eintraege.len().saturating_sub(1);
}
self.sichtbar_halten();
}
// Zeichnet das Panel. Nur das aktive Panel zeigt den Balken und einen hellen Kopf.
fn ansicht(&self, aktiv: bool) -> Element<'_, Message> {
let kopffarbe = if aktiv { SCHWARZ } else { GELB };
let titel = match &self.server {
Some(ftp) => format!("{}:{}", ftp.name, pfad_text(&self.ordner)),
None => self.ordner.display().to_string(),
};
let kopf = container(text(titel).color(kopffarbe))
.style(move |_theme: &Theme| container::Style {
background: if aktiv {
Some(Background::Color(TUERKIS))
} else {
None
},
..container::Style::default()
});
let mut liste = column![];
for (nr, eintrag) in self
.eintraege
.iter()
.enumerate()
.skip(self.erste_zeile)
.take(ZEILEN)
{
liste = liste.push(zeile(eintrag, aktiv && nr == self.cursor));
}
container(column![kopf, liste].spacing(6))
.width(Fill)
.height(Fill)
.padding(8)
.clip(true)
.style(|_theme: &Theme| container::Style {
border: Border {
color: TUERKIS,
width: 2.0,
radius: 0.0.into(),
},
..container::Style::default()
})
.into()
}
}
// Baut eine einzelne Taste der Funktionstastenleiste, z. B. "5 Kopie".
fn f_taste(nummer: u8, beschriftung: &'static str) -> Element<'static, Message> {
let zahl = text(nummer).color(WEISS);
let feld = container(text(beschriftung))
.width(Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(TUERKIS)),
text_color: Some(SCHWARZ),
..container::Style::default()
});
row![zahl, feld].width(Fill).into()
}
// Liest einen Ordner und liefert seine Einträge: oben "..", dann Ordner, dann Dateien.
fn ordner_lesen(pfad: &Path) -> Vec<Eintrag> {
let mut eintraege = Vec::new();
let Ok(inhalt) = fs::read_dir(pfad) else {
return eintraege; // Ordner nicht lesbar: leere Liste
};
for eintrag in inhalt.flatten() {
let name = eintrag.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue; // versteckte Dateien überspringen
}
let Ok(info) = eintrag.metadata() else {
continue; // keine Infos lesbar: Eintrag überspringen
};
eintraege.push(Eintrag {
name,
ist_ordner: info.is_dir(),
groesse: info.len(),
rechte: rechte_lesen(&info),
});
}
liste_fertigmachen(&mut eintraege, pfad.parent().is_some());
eintraege
}
// Sortiert eine Liste (Ordner zuerst, dann alphabetisch) und setzt ".." an den Anfang.
fn liste_fertigmachen(eintraege: &mut Vec<Eintrag>, hat_eltern: bool) {
eintraege.sort_by(|a, b| {
b.ist_ordner
.cmp(&a.ist_ordner)
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
if hat_eltern {
let hoch = Eintrag {
name: String::from(".."),
ist_ordner: true,
groesse: 0,
rechte: 0,
};
eintraege.insert(0, hoch);
}
}
// Fragt den Server nach dem Inhalt eines Ordners (ohne "..").
async fn ftp_eintraege(strom: &mut AsyncFtpStream, pfad: &str) -> Result<Vec<Eintrag>, String> {
let zeilen = strom.list(Some(pfad)).await.map_err(|e| e.to_string())?;
let mut eintraege = Vec::new();
for zeile in zeilen {
let Ok(datei) = File::try_from(zeile.as_str()) else {
continue; // eine Zeile, die wir nicht verstehen: überspringen
};
if datei.name().starts_with('.') {
continue;
}
eintraege.push(Eintrag {
name: datei.name().to_string(),
ist_ordner: datei.is_directory(),
groesse: datei.size() as u64,
rechte: ftp_rechte(&datei),
});
}
Ok(eintraege)
}
// Liest die Rechte aus einer Zeile der Serverliste.
fn ftp_rechte(datei: &File) -> u32 {
let mut rechte = 0;
let gruppen = [PosixPexQuery::Owner, PosixPexQuery::Group, PosixPexQuery::Others];
for (reihe, wer) in gruppen.into_iter().enumerate() {
if datei.can_read(wer) {
rechte |= bit(reihe * 3);
}
if datei.can_write(wer) {
rechte |= bit(reihe * 3 + 1);
}
if datei.can_execute(wer) {
rechte |= bit(reihe * 3 + 2);
}
}
rechte
}
// Macht aus einem Pfad einen Text mit "/" als Trenner, so wie Server ihn erwarten.
fn pfad_text(pfad: &Path) -> String {
pfad.to_string_lossy().replace('\\', "/")
}
// Verbindet sich mit dem Server, meldet sich an und liest den Startordner.
async fn ftp_verbinden(zugang: Zugang) -> Result<(Server, PathBuf, Vec<Eintrag>), String> {
let adresse = format!("{}:{}", zugang.host.trim(), zugang.port.trim());
let mut strom = AsyncFtpStream::connect(adresse.as_str())
.await
.map_err(|e| e.to_string())?;
strom
.login(&zugang.benutzer, &zugang.passwort)
.await
.map_err(|e| e.to_string())?;
strom
.transfer_type(FileType::Binary)
.await
.map_err(|e| e.to_string())?;
let start = strom.pwd().await.map_err(|e| e.to_string())?;
let mut eintraege = ftp_eintraege(&mut strom, &start).await?;
liste_fertigmachen(&mut eintraege, start != "/");
let ftp = Server {
strom: Arc::new(Mutex::new(Strom::Ftp(strom))),
name: format!("{}@{}", zugang.benutzer, zugang.host),
fingerabdruck: None,
};
Ok((ftp, PathBuf::from(start), eintraege))
}
// Verbindet sich per SSH, meldet sich an, öffnet SFTP und liest den Startordner.
async fn sftp_verbinden(zugang: Zugang) -> Result<(Server, PathBuf, Vec<Eintrag>), String> {
let port: u16 = zugang
.port
.trim()
.parse()
.map_err(|_| format!("„{}“ ist kein gültiger Port.", zugang.port))?;
let fingerabdruck = Arc::new(std::sync::Mutex::new(String::new()));
let pruefer = Pruefer {
fingerabdruck: fingerabdruck.clone(),
};
let einstellungen = Arc::new(russh::client::Config::default());
let mut sitzung = russh::client::connect(einstellungen, (zugang.host.trim(), port), pruefer)
.await
.map_err(als_text)?;
let anmeldung = sitzung
.authenticate_password(&zugang.benutzer, &zugang.passwort)
.await
.map_err(als_text)?;
if !anmeldung.success() {
return Err(String::from("Benutzername oder Passwort stimmt nicht."));
}
let kanal = sitzung.channel_open_session().await.map_err(als_text)?;
kanal.request_subsystem(true, "sftp").await.map_err(als_text)?;
let sftp = SftpSession::new(kanal.into_stream()).await.map_err(als_text)?;
let start = sftp.canonicalize(".").await.map_err(als_text)?;
let mut strom = Strom::Sftp {
sftp,
_sitzung: sitzung,
};
let mut eintraege = strom.eintraege(&start).await?;
liste_fertigmachen(&mut eintraege, start != "/");
let server = Server {
strom: Arc::new(Mutex::new(strom)),
name: format!("{}@{}", zugang.benutzer, zugang.host),
fingerabdruck: Some(fingerabdruck.lock().unwrap().clone()),
};
Ok((server, PathBuf::from(start), eintraege))
}
// Liest einen Ordner auf dem Server.
async fn server_lesen(ftp: Server, pfad: PathBuf) -> Result<(PathBuf, Vec<Eintrag>), String> {
let mut strom = ftp.strom.lock().await;
let mut eintraege = strom.eintraege(&pfad_text(&pfad)).await?;
liste_fertigmachen(&mut eintraege, pfad.parent().is_some());
Ok((pfad, eintraege))
}
// Beantwortet beim SSH-Verbindungsaufbau die Frage: "Ist das wirklich der richtige Server?"
// Wir merken uns dabei den Fingerabdruck des Servers, um ihn anzuzeigen.
struct Pruefer {
fingerabdruck: Arc<std::sync::Mutex<String>>,
}
impl russh::client::Handler for Pruefer {
type Error = russh::Error;
async fn check_server_key(
&mut self,
schluessel: &PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
if let PublicKeyOrCertificate::PublicKey { key, .. } = schluessel {
*self.fingerabdruck.lock().unwrap() = key.fingerprint(HashAlg::Sha256).to_string();
}
Ok(true) // Wir vertrauen jedem Server. Mehr dazu im Kasten in Schritt 5!
}
}
// Eine Leitung zum Server. Jede Art von Server spricht ihre eigene Sprache.
enum Strom {
Ftp(AsyncFtpStream),
Sftp {
sftp: SftpSession,
_sitzung: russh::client::Handle<Pruefer>, // hält die SSH-Verbindung am Leben
},
}
// Was man mit jeder Leitung tun kann, egal welche Sprache der Server spricht.
impl Strom {
// Liest einen Ordner (ohne "..").
async fn eintraege(&mut self, pfad: &str) -> Result<Vec<Eintrag>, String> {
match self {
Strom::Ftp(ftp) => ftp_eintraege(ftp, pfad).await,
Strom::Sftp { sftp, .. } => {
let mut eintraege = Vec::new();
for eintrag in sftp.read_dir(pfad).await.map_err(als_text)? {
let name = eintrag.file_name();
if name.starts_with('.') {
continue;
}
let info = eintrag.metadata();
eintraege.push(Eintrag {
name,
ist_ordner: eintrag.file_type().is_dir(),
groesse: info.size.unwrap_or(0),
rechte: info.permissions.unwrap_or(0) & 0o777,
});
}
Ok(eintraege)
}
}
}
// Legt einen Ordner an.
async fn ordner_anlegen(&mut self, pfad: &str) -> Result<(), String> {
match self {
Strom::Ftp(ftp) => ftp.mkdir(pfad).await.map_err(als_text),
Strom::Sftp { sftp, .. } => sftp.create_dir(pfad).await.map_err(als_text),
}
}
// Schickt eine Datei von diesem Rechner zum Server.
async fn datei_hoch(&mut self, von: &Path, nach: &str) -> Result<(), String> {
let mut datei = tokio::fs::File::open(von).await.map_err(als_text)?;
match self {
Strom::Ftp(ftp) => {
ftp.put_file(nach, &mut datei).await.map_err(als_text)?;
}
Strom::Sftp { sftp, .. } => {
let mut ziel = sftp.create(nach).await.map_err(als_text)?;
tokio::io::copy(&mut datei, &mut ziel).await.map_err(als_text)?;
ziel.shutdown().await.map_err(als_text)?;
}
}
Ok(())
}
// Holt eine Datei vom Server auf diesen Rechner.
async fn datei_runter(&mut self, von: &str, nach: &Path) -> Result<(), String> {
let mut datei = tokio::fs::File::create(nach).await.map_err(als_text)?;
match self {
Strom::Ftp(ftp) => {
let mut quelle = ftp.retr_as_stream(von).await.map_err(als_text)?;
tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?;
quelle.finish().await.map_err(als_text)?;
}
Strom::Sftp { sftp, .. } => {
let mut quelle = sftp.open(von).await.map_err(als_text)?;
tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?;
}
}
Ok(())
}
// Setzt die Rechte. FTP braucht dafür SITE CHMOD, das nicht jeder Server kann.
async fn rechte_setzen(&mut self, pfad: &str, rechte: u32) -> Result<(), String> {
match self {
Strom::Ftp(ftp) => {
let befehl = format!("CHMOD {:03o} {}", rechte, pfad);
ftp.site(befehl).await.map(|_| ()).map_err(als_text)
}
Strom::Sftp { sftp, .. } => {
let mut attribute = FileAttributes::empty();
attribute.permissions = Some(rechte);
sftp.set_metadata(pfad, attribute).await.map_err(als_text)
}
}
}
}
// Was bei einer Übertragung zusammengekommen ist.
#[derive(Default)]
struct Bilanz {
dateien: u64,
ohne_rechte: u64, // Einträge, bei denen sich die Rechte nicht übernehmen ließen
}
impl Bilanz {
// Macht aus der Bilanz eine Meldung für die Meldungszeile.
fn text(&self, name: &str, wie: &str) -> String {
let mut text = format!("„{}“ {}: {} Datei(en).", name, wie, self.dateien);
if self.ohne_rechte > 0 {
text += &format!(
" Bei {} Einträgen ließen sich die Rechte nicht übernehmen.",
self.ohne_rechte
);
}
text
}
}
// Macht aus jedem Fehler einen Text. So haben alle Fehler dieselbe Form.
fn als_text<E: fmt::Display>(fehler: E) -> String {
fehler.to_string()
}
// Der Rückgabetyp einer Übertragung: eine Aufgabe, die später fertig wird.
type Uebertragung<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
// Bringt eine Datei oder einen ganzen Ordner auf den Server, samt Rechten.
fn hoch<'a>(
strom: &'a mut Strom,
von: &'a Path,
nach: &'a str,
bilanz: &'a mut Bilanz,
) -> Uebertragung<'a> {
Box::pin(async move {
let info = fs::metadata(von).map_err(als_text)?;
if info.is_dir() {
strom.ordner_anlegen(nach).await?;
for eintrag in fs::read_dir(von).map_err(als_text)? {
let eintrag = eintrag.map_err(als_text)?;
if eintrag.file_type().map_err(als_text)?.is_symlink() {
continue; // Verknüpfungen lassen wir aus
}
let name = eintrag.file_name().to_string_lossy().to_string();
hoch(strom, &eintrag.path(), &format!("{}/{}", nach, name), bilanz).await?;
}
} else {
strom.datei_hoch(von, nach).await?;
bilanz.dateien += 1;
}
// Rechte mitnehmen. Nicht jeder Server kann das, darum nur zählen, nicht abbrechen.
if strom.rechte_setzen(nach, rechte_lesen(&info)).await.is_err() {
bilanz.ohne_rechte += 1;
}
Ok(())
})
}
// Lädt hoch und meldet das Ergebnis.
async fn hochladen(ftp: Server, von: PathBuf, nach: PathBuf, name: String) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
let mut bilanz = Bilanz::default();
hoch(&mut strom, &von, &pfad_text(&nach), &mut bilanz)
.await
.map_err(|fehler| format!("Hochladen von „{}“ fehlgeschlagen: {}", name, fehler))?;
Ok(bilanz.text(&name, "hochgeladen"))
}
// Holt eine Datei oder einen ganzen Ordner vom Server, samt Rechten.
fn runter<'a>(
strom: &'a mut Strom,
von: &'a str,
nach: &'a Path,
ist_ordner: bool,
rechte: u32,
bilanz: &'a mut Bilanz,
) -> Uebertragung<'a> {
Box::pin(async move {
if ist_ordner {
fs::create_dir(nach).map_err(als_text)?;
for eintrag in strom.eintraege(von).await? {
let unter_von = format!("{}/{}", von, eintrag.name);
let unter_nach = nach.join(&eintrag.name);
runter(strom, &unter_von, &unter_nach, eintrag.ist_ordner, eintrag.rechte, bilanz)
.await?;
}
} else {
strom.datei_runter(von, nach).await?;
bilanz.dateien += 1;
}
// Die Rechte vom Server übernehmen, bei Ordnern erst ganz zum Schluss
if rechte_setzen(nach, rechte).is_err() {
bilanz.ohne_rechte += 1;
}
Ok(())
})
}
// Lädt herunter und meldet das Ergebnis.
async fn herunterladen(
ftp: Server,
von: PathBuf,
nach: PathBuf,
ist_ordner: bool,
rechte: u32,
name: String,
) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
let mut bilanz = Bilanz::default();
runter(&mut strom, &pfad_text(&von), &nach, ist_ordner, rechte, &mut bilanz)
.await
.map_err(|fehler| format!("Herunterladen von „{}“ fehlgeschlagen: {}", name, fehler))?;
Ok(bilanz.text(&name, "heruntergeladen"))
}
// Ändert die Rechte einer Datei oder eines Ordners auf dem Server.
async fn server_rechte_setzen(ftp: Server, pfad: PathBuf, rechte: u32, name: String) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
match strom.rechte_setzen(&pfad_text(&pfad), rechte).await {
Ok(()) => Ok(format!(
"Rechte von „{}“ auf dem Server sind jetzt {} ({:03o}).",
name,
rechte_text(rechte),
rechte
)),
Err(fehler) => Err(format!("Der Server lehnt neue Rechte für „{}“ ab: {}", name, fehler)),
}
}
// Baut eine Zeile im Panel: links der Name, rechts die Größe.
fn zeile(eintrag: &Eintrag, markiert: bool) -> Element<'_, Message> {
let farbe = if markiert {
SCHWARZ
} else if eintrag.ist_ordner {
WEISS
} else {
HELLTUERKIS
};
let groesse = if eintrag.ist_ordner {
String::from("<ORDNER>")
} else {
groesse_text(eintrag.groesse)
};
let rechte = if eintrag.name == ".." {
String::new()
} else {
rechte_text(eintrag.rechte)
};
let inhalt = row![
text(eintrag.name.as_str())
.color(farbe)
.width(Fill)
.wrapping(text::Wrapping::None),
text(rechte).color(farbe),
text(groesse).color(farbe).width(90).align_x(Right),
]
.spacing(12);
// Der markierte Eintrag bekommt den türkisen Balken
container(inhalt)
.width(Fill)
.style(move |_theme: &Theme| container::Style {
background: if markiert {
Some(Background::Color(TUERKIS))
} else {
None
},
..container::Style::default()
})
.into()
}
// Macht aus einer Zahl von Bytes etwas Lesbares, zum Beispiel "12,3 KB".
fn groesse_text(bytes: u64) -> String {
let b = bytes as f64;
let text = if bytes < 1024 {
format!("{} B", bytes)
} else if b < 1024.0 * 1024.0 {
format!("{:.1} KB", b / 1024.0)
} else if b < 1024.0 * 1024.0 * 1024.0 {
format!("{:.1} MB", b / 1024.0 / 1024.0)
} else {
format!("{:.1} GB", b / 1024.0 / 1024.0 / 1024.0)
};
text.replace('.', ",")
}
// Kopiert einen ganzen Ordner mit allem, was darin liegt, und liefert die Zahl der Dateien.
// Die Rechte jeder Datei und jedes Ordners werden dabei mitgenommen.
fn ordner_kopieren(von: &Path, nach: &Path) -> io::Result<u64> {
fs::create_dir(nach)?;
let mut dateien = 0;
for eintrag in fs::read_dir(von)? {
let eintrag = eintrag?;
let art = eintrag.file_type()?;
let ziel = nach.join(eintrag.file_name());
if art.is_symlink() {
continue; // Verknüpfungen lassen wir aus
} else if art.is_dir() {
dateien += ordner_kopieren(&eintrag.path(), &ziel)?;
} else {
fs::copy(eintrag.path(), &ziel)?; // nimmt die Rechte der Datei mit
dateien += 1;
}
}
// Die Rechte des Ordners erst zum Schluss setzen: Ein schreibgeschützter
// Ordner ließe sich sonst gar nicht mehr befüllen.
fs::set_permissions(nach, fs::metadata(von)?.permissions())?;
Ok(dateien)
}
// Liefert die Nummer eines Rechte-Bits: 0 ist "Besitzer darf lesen", 8 ist "Andere dürfen ausführen".
fn bit(nr: usize) -> u32 {
1 << (8 - nr)
}
// Macht aus den Rechte-Bits einen Text wie "rwxr-xr-x".
fn rechte_text(rechte: u32) -> String {
let zeichen = ['r', 'w', 'x'];
let mut text = String::new();
for nr in 0..9 {
if rechte & bit(nr) != 0 {
text.push(zeichen[nr % 3]);
} else {
text.push('-');
}
}
text
}
// Liest die Rechte aus den Infos einer Datei.
fn rechte_lesen(info: &fs::Metadata) -> u32 {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
info.permissions().mode() & 0o777
}
#[cfg(not(unix))]
{
// Windows kennt nur "schreibgeschützt" oder nicht
if info.permissions().readonly() { 0o444 } else { 0o666 }
}
}
// Setzt die Rechte einer Datei oder eines Ordners.
fn rechte_setzen(pfad: &Path, rechte: u32) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(pfad, fs::Permissions::from_mode(rechte))
}
#[cfg(not(unix))]
{
let mut erlaubnis = fs::metadata(pfad)?.permissions();
erlaubnis.set_readonly(rechte & 0o200 == 0); // ohne Schreibrecht: schreibgeschützt
fs::set_permissions(pfad, erlaubnis)
}
}
// Das Aussehen aller Dialoge: grau mit schwarzem Rahmen, wie im Original.
fn dialog_stil(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(GRAU)),
text_color: Some(SCHWARZ),
border: Border {
color: SCHWARZ,
width: 2.0,
radius: 0.0.into(),
},
..container::Style::default()
}
}
// Zeichnet den Verbindungsdialog mit vier Eingabefeldern.
fn verbindung_ansicht(zugang: &Zugang) -> Element<'_, Message> {
let inhalt = column![
text("Mit einem Server verbinden"),
text(if zugang.sftp {
"Protokoll: SFTP (verschlüsselt) · F3 wechselt"
} else {
"Protokoll: FTP (unverschlüsselt) · F3 wechselt"
}),
eingabezeile("Server", &zugang.host, Feld::Host, "host", false),
eingabezeile("Port", &zugang.port, Feld::Port, "port", false),
eingabezeile("Benutzer", &zugang.benutzer, Feld::Benutzer, "benutzer", false),
eingabezeile("Passwort", &zugang.passwort, Feld::Passwort, "passwort", true),
text("Tab: nächstes Feld · Enter: verbinden · Esc Esc: abbrechen").size(13),
]
.spacing(12);
container(inhalt)
.padding(18)
.width(480)
.style(dialog_stil)
.into()
}
// Eine Zeile im Verbindungsdialog: links die Beschriftung, rechts das Eingabefeld.
fn eingabezeile<'a>(
beschriftung: &'a str,
wert: &'a str,
feld: Feld,
id: &'static str,
geheim: bool,
) -> Element<'a, Message> {
row![
text(beschriftung).width(110),
text_input("", wert)
.id(id)
.secure(geheim)
.on_input(move |neu| Message::Eingabe(feld, neu))
.on_submit(Message::Verbinden)
.padding(4),
]
.align_y(Center)
.into()
}
// Zeichnet den Rechte-Dialog als graue Box im Commander-Stil.
fn dialog_ansicht(dialog: &RechteDialog) -> Element<'_, Message> {
let mut gitter = column![row![
text("").width(110),
text("Lesen").width(110),
text("Schreiben").width(110),
text("Ausführen").width(110),
]]
.spacing(4);
for (reihe_nr, wer) in ["Besitzer", "Gruppe", "Andere"].iter().enumerate() {
let mut reihe = row![text(*wer).width(110)];
for spalte in 0..3 {
let nr = reihe_nr * 3 + spalte;
let an = dialog.rechte & bit(nr) != 0;
let gewaehlt = nr == dialog.auswahl;
let kaestchen = text(if an { "[x]" } else { "[ ]" })
.color(if gewaehlt { WEISS } else { SCHWARZ });
reihe = reihe.push(container(kaestchen).width(110).style(move |_theme: &Theme| {
container::Style {
background: if gewaehlt {
Some(Background::Color(SCHWARZ))
} else {
None
},
..container::Style::default()
}
}));
}
gitter = gitter.push(reihe);
}
let inhalt = column![
text(format!("Rechte für „{}“", dialog.name)),
gitter,
text(format!("Zahlencode: {:03o}", dialog.rechte)),
text("Pfeile: wählen · Leertaste: an/aus · Enter: übernehmen · Esc: abbrechen").size(13),
]
.spacing(14);
container(inhalt)
.padding(18)
.style(dialog_stil)
.into()
}
// Übersetzt einen Tastendruck in eine Nachricht, oder in gar nichts.
fn taste(ereignis: keyboard::Event) -> Option<Message> {
let keyboard::Event::KeyPressed { key, .. } = ereignis else {
return None; // Taste losgelassen oder Ähnliches: interessiert uns nicht
};
match key {
Key::Named(Named::ArrowUp) => Some(Message::Hoch),
Key::Named(Named::ArrowDown) => Some(Message::Runter),
Key::Named(Named::Enter) => Some(Message::Oeffnen),
Key::Named(Named::Tab) => Some(Message::Wechseln),
Key::Named(Named::F5) => Some(Message::Kopieren),
Key::Named(Named::F2) => Some(Message::VerbindenZeigen),
Key::Named(Named::F3) => Some(Message::ProtokollWechseln),
Key::Named(Named::F9) => Some(Message::RechteZeigen),
Key::Named(Named::ArrowLeft) => Some(Message::PfeilLinks),
Key::Named(Named::ArrowRight) => Some(Message::PfeilRechts),
Key::Named(Named::Space) => Some(Message::Umschalten),
Key::Named(Named::Escape) => Some(Message::Abbrechen),
Key::Named(Named::F10) => Some(Message::Beenden),
_ => None,
}
}
enum mit einem impl kann verschiedene Umsetzungen hinter
einer gemeinsamen Schnittstelle verbergen, und eine neue Variante zeigt der Compiler an jeder
Stelle, wo sie noch fehlt. SFTP läuft über SSH und verschlüsselt alles. Ein
Handler ist ein selbstgeschriebener Trait, der Fragen der Bibliothek
beantwortet. Bei Crates kann man Standard-Features abschalten und selbst wählen. Und
parse() wandelt Text in Zahlen um, mit einem Result für
Tippfehler.Zum Ausprobieren
Die Übungen sind freiwillig, aber sie helfen enorm. Mach nach jeder Übung einen Probelauf, und stell danach den alten Zustand wieder her, damit die nächste Etappe bei dir genauso weitergeht wie bei allen anderen.
- Schlüssel merken: Speichere beim ersten Verbinden den Fingerabdruck in einer
Datei (zum Beispiel
faehrmann_hosts.txt, eine Zeile pro Server) und lasscheck_server_keymitOk(false)ablehnen, wenn sich der Fingerabdruck eines bekannten Servers geändert hat. - Welche Sprache? Lass den Kopf des rechten Panels zeigen, ob die Verbindung
FTP oder SFTP ist, zum Beispiel
sftp://volker@localhost:/home/volker. - Gleiche Rechte, andere Wege: Starte deinen FTP-Testserver ohne
-wund lade denselben Ordner einmal per FTP und einmal per SFTP hoch (wenn der SFTP-Server auf demselben Rechner läuft, in einen Unterordner deines Home-Ordners). Was meldet die Bilanz jeweils?
Lösungen anzeigen
1. In check_server_key: Datei mit std::fs::read_to_string lesen (bei
Fehler leer), nach einer Zeile suchen, die mit dem Servernamen beginnt. Gibt es sie und der
Fingerabdruck ist anders, Ok(false). Gibt es sie nicht, die Zeile mit
std::fs::OpenOptions::new().append(true).create(true) anhängen. Dafür braucht
der Pruefer ein zusätzliches Feld mit dem Servernamen. Lehnt der Pruefer ab,
bricht connect mit einem Fehler ab, und die Meldungszeile zeigt ihn.
2. In ansicht: let art = match server.fingerabdruck { Some(_) => "sftp",
None => "ftp" }; und dann format!("{}://{}:{}", art, server.name,
pfad_text(&self.ordner)). Ein bisschen geschummelt, denn der Fingerabdruck verrät
das Protokoll. Sauberer wäre ein eigenes Feld protokoll im
Server.
3. Per FTP scheitert schon das Anlegen des Ordners, denn ohne -w darf der Fahrgast
nichts schreiben. Per SFTP klappt alles, und die Bilanz meldet keine Probleme mit den
Rechten, denn SFTP setzt sie immer mit set_metadata. Hier zeigt sich, dass
dieselbe Leitung ganz unterschiedliche Server mit ganz unterschiedlichen Regeln
verbirgt.
Etappe 11 geschafft 🔒
Die Fähre fährt jetzt auch verschlüsselt. Hinter einer gemeinsamen Leitung sprechen FTP und SFTP ihre eigene Sprache, und alles, was du in Etappe 10 gebaut hast, funktioniert mit beiden. In der nächsten Etappe sorgen wir dafür, dass man bei großen Dateien sieht, wie die Fahrt vorangeht, und dass Fehlermeldungen verständlich werden.
Hat dir das Mitbauen Spaß gemacht? Ein Kaffee hilft, dass die nächste Etappe ein Stück schneller fertig wird.
☕ Kaffee spendieren