Rust-Werkstatt
Werkstatt · Fährmann · Etappe 11 von 13

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.

6 Schritte · ca. 75 Minuten · baut auf Etappe 10 auf

Der Fährmann, rechts per SFTP verbunden, in der Meldungszeile der Fingerabdruck des Servers
So sieht der Fährmann am Ende dieser Etappe aus.
Vorab

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.

Schritt 1

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-server und sudo systemctl start sshd. Auf Debian und Ubuntu heißt es sudo apt install openssh-server, der Dienst heißt dort ssh.
  • 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
Probelauf Speichern, dann im Terminal:
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.
Sicherheit Ein SSH-Server erlaubt Anmeldungen an deinem Rechner. Im Heimnetz hinter dem Router ist das normalerweise kein Problem, im Café-WLAN schon eher. Wenn du ihn nach dem Üben nicht mehr brauchst, schalte ihn wieder aus, unter Linux etwa mit sudo systemctl stop sshd.
Schritt 2

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 klickstAlter NameNeuer Name
bei struct FtpFtpServer
im struct Panelftpserver
im struct RechteDialogftpserver
bei async fn ftp_lesenftp_lesenserver_lesen
bei async fn ftp_rechte_setzenftp_rechte_setzenserver_rechte_setzen
im enum MessageFtpGelesenGelesen
bei fn ftp_gelesenftp_gelesengelesen

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“.

Ohne rust-analyzer? Dann hilft „Suchen und Ersetzen“ deines Editors, aber mit Vorsicht: Ersetze 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.
Probelauf Speichern, dann im Terminal:
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.
Schritt 3

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:

src/main.rsdas struct Server
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:

src/main.rshinter der Funktion 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:

src/main.rsin den Funktionen ftp_verbinden und server_lesen
    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:

src/main.rsdie Funktion hoch
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() {
src/main.rsdie Funktion runter
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:

src/main.rsin der Funktion server_rechte_setzen
    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!(
Warum ein enum und kein Trait? Wer schon etwas Rust kann, würde hier vielleicht einen Trait schreiben, also eine gemeinsame Schnittstelle, die FTP und SFTP jeweils umsetzen. Das ginge auch. Ein 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.
Probelauf Speichern, dann im Terminal:
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.
Schritt 4

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:

src/main.rsganz oben, bei den Crates
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.“

src/main.rsdas enum Strom
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:

src/main.rsin impl Strom, in jeder der fünf Funktionen
            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 & 0o777 nur 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ßt shutdown die 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 mit set_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:

src/main.rsdirekt vor dem 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.

Probelauf Speichern, dann im Terminal:
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.
Schritt 5

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:

src/main.rsdas struct Server
    name: String, // zum Beispiel "fahrgast@localhost"    fingerabdruck: Option<String>, // nur bei SFTP}
src/main.rsam Ende von ftp_verbinden
        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:

src/main.rsdirekt hinter der Funktion ftp_verbinden
    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 : u16 sagt parse, welche Art Zahl herauskommen soll. Ports gehen bis 65535, genau der Bereich von u16.
  • Verbinden: russh::client::connect baut die verschlüsselte Verbindung auf und fragt dabei unseren Pruefer nach dem Schlüssel. Weil wir ihm einen Klon des Arc mitgeben, können wir den Fingerabdruck danach selbst lesen.
  • Anmelden: authenticate_password schickt Name und Passwort, diesmal verschlüsselt. Ein falsches Passwort ist bei SSH kein Fehler im Sinne von Err, sondern eine Antwort „hat nicht geklappt“. Darum fragen wir success() 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.
Vertrauen wir wirklich jedem Server? Im Moment ja, und das ist eine bewusste Vereinfachung. Ein Angreifer im selben Netz könnte sich als dein Server ausgeben, und der Fährmann würde es nicht merken. Programme wie 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.
Probelauf Speichern, dann im Terminal:
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.
Schritt 6

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:

src/main.rsdas struct Zugang
struct Zugang {    sftp: bool, // true: SFTP, false: FTP    host: String,

Umgeschaltet wird mit F3. Eine Briefsorte und eine Taste:

src/main.rsdas enum Message
    Verbinden,    ProtokollWechseln,
src/main.rsin der Funktion taste
        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:

src/main.rsin update, im Verbindungsdialog
                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:

src/main.rsin verbindung_zeigen
        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:

src/main.rsin der Funktion verbinden
        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:

src/main.rsin der Funktion verbunden
            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:

src/main.rsin der Funktion verbindung_ansicht
        text("Mit einem Server verbinden"),        text(if zugang.sftp {
            "Protokoll: SFTP (verschlüsselt) · F3 wechselt"
        } else {
            "Protokoll: FTP (unverschlüsselt) · F3 wechselt"
        }),
Probelauf Speichern, dann im Terminal:
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.
Per SFTP verbunden
Stand nach Schritt 6: per SFTP verbunden, unten der Fingerabdruck
Zwischenstand

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,
    }
}
ZusammenfassungUmbenennen erledigt rust-analyzer sicher und überall auf einmal. Ein 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.
Übungen

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.

  1. Schlüssel merken: Speichere beim ersten Verbinden den Fingerabdruck in einer Datei (zum Beispiel faehrmann_hosts.txt, eine Zeile pro Server) und lass check_server_key mit Ok(false) ablehnen, wenn sich der Fingerabdruck eines bekannten Servers geändert hat.
  2. Welche Sprache? Lass den Kopf des rechten Panels zeigen, ob die Verbindung FTP oder SFTP ist, zum Beispiel sftp://volker@localhost:/home/volker.
  3. Gleiche Rechte, andere Wege: Starte deinen FTP-Testserver ohne -w und 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