diff --git a/prg/SJNet_primzalen_calc.py b/prg/SJNet_primzalen_calc.py new file mode 100644 index 0000000..e866dbc --- /dev/null +++ b/prg/SJNet_primzalen_calc.py @@ -0,0 +1,33 @@ +#import time +print("Prime number calc") +checked = [] +primes=[] +now=1 + +def is_ganz(zahl): + try: + return float(int(zahl)) == zahl + except ValueError: + return False + + +def is_teilbar(nummer, teiler): + geteilt=nummer/teiler + return is_ganz(geteilt) + + + +while True: + now += 1 + #if is_teilbar(now, 50): + # print("[debug]: das ist cheecked:"+str(checked)) + if not now in checked: + x=0 + for i in checked: + if is_teilbar(now, i): + x += 1 + if x==0: + primes.append(now) + print(now) + checked.append(now) + #time.sleep(0.01) diff --git a/prg/SJNet_strguesser.py b/prg/SJNet_strguesser.py new file mode 100644 index 0000000..ce5c7d5 --- /dev/null +++ b/prg/SJNet_strguesser.py @@ -0,0 +1,37 @@ +import sys +import random +import time + + +if sys.stdin.isatty(): + word = list(input("Gib das zu erratende Wort ein:\n>")) +else: + word = list(sys.stdin.read().strip()) +printable_ascii = [ + ' ', '!', '"', '#', '$', '%', '&', "'", '(', ')', + '*', '+', ',', '-', '.', '/', '0', '1', '2', '3', + '4', '5', '6', '7', '8', '9', ':', ';', '<', '=', + '>', '?', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', + 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', + '\\', ']', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', + 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', + 'z', '{', '|', '}', '~' +] + +guess = [] + +for i in word: + guess.append(random.choice(printable_ascii)) +print("".join(guess), end="") + +while True: + if guess != word: + for l in range(len(guess)): + if guess[l]!=word[l]: + guess[l]=random.choice(printable_ascii) + print("\r"+"".join(guess), end="", flush=True ) + time.sleep(0.05) + else: + break diff --git a/sjcopy/sjcopy/Cargo.lock b/sjcopy/sjcopy/Cargo.lock new file mode 100644 index 0000000..714ca4d --- /dev/null +++ b/sjcopy/sjcopy/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "sjcopy" +version = "0.1.0" diff --git a/sjcopy/sjcopy/Cargo.toml b/sjcopy/sjcopy/Cargo.toml new file mode 100644 index 0000000..84111e6 --- /dev/null +++ b/sjcopy/sjcopy/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sjcopy" +version = "0.1.0" +edition = "2021" +description = "Kopiere Terminal-Output in benannte zeitlich begrenzte Ablagen (copy) und hole ihn zurück (paste)." +license = "MIT" + +[dependencies] + +[[bin]] +name = "copy" +path = "src/bin/copy.rs" + +[[bin]] +name = "paste" +path = "src/bin/paste.rs" diff --git a/sjcopy/sjcopy/PKGBUILD b/sjcopy/sjcopy/PKGBUILD new file mode 100644 index 0000000..47dea1c --- /dev/null +++ b/sjcopy/sjcopy/PKGBUILD @@ -0,0 +1,56 @@ +# Maintainer: +# Contributor: + +pkgname=sjcopy +pkgver=0.1.0 +pkgrel=1 +pkgdesc='Copy terminal output to named, automatically expiring stores (copy) and read it back byte-exact (paste)' +arch=('x86_64') +# Set this to the actual repository URL. +url='https://example.org/sjcopy' +license=('MIT') +depends=('gcc-libs') +makedepends=('cargo') +checkdepends=() +source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz") +sha256sums=('# TODO: fill in with makepkg -g') +# Binaries must not be installed as "copy"/"paste": "paste" collides with +# GNU coreutils in /usr/bin. The sj prefix matches the README's naming advice. +_sjbin_prefix='sj' + +prepare() { + cd "$pkgname-$pkgver" + cargo fetch --locked --target "$CARCH-unknown-linux-gnu" +} + +build() { + cd "$pkgname-$pkgver" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR="$srcdir/target" + cargo build --frozen --release --target "$CARCH-unknown-linux-gnu" +} + +check() { + cd "$pkgname-$pkgver" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR="$srcdir/target" + cargo test --frozen --release --target "$CARCH-unknown-linux-gnu" +} + +package() { + cd "$pkgname-$pkgver" + + install -Dt "$pkgdir/usr/bin" -m 0755 \ + "$srcdir/target/$CARCH-unknown-linux-gnu/release/copy" + install -Dt "$pkgdir/usr/bin" -m 0755 \ + "$srcdir/target/$CARCH-unknown-linux-gnu/release/paste" + + mv "$pkgdir/usr/bin/copy" "$pkgdir/usr/bin/${_sjbin_prefix}copy" + mv "$pkgdir/usr/bin/paste" "$pkgdir/usr/bin/${_sjbin_prefix}paste" + + # Only valid once a LICENSE file exists at the project root. With + # license=('MIT') and no license file, makepkg/lint will complain. + if [[ -f LICENSE ]]; then + install -Dt "$pkgdir/usr/share/licenses/$pkgname" -m 0644 LICENSE + fi +} diff --git a/sjcopy/sjcopy/README.md b/sjcopy/sjcopy/README.md new file mode 100644 index 0000000..bfe88ff --- /dev/null +++ b/sjcopy/sjcopy/README.md @@ -0,0 +1,167 @@ +# sjcopy + +`sjcopy` ist ein kleines Unix-artiges Rust-Projekt mit **zwei separaten +Binaries**: + +- `copy` liest alles von stdin und legt es unter einem Namen temporär ab. +- `paste` gibt eine zuvor abgelegte Ablage bytegenau nach stdout aus. + +Die Ablagen liegen im Laufzeitverzeichnis des Benutzers +(`/run/user/$UID/sjcopy/`) und verschwinden beim Neustart automatisch, weil +`/run` eine temporäre Dateisystem-Hierarchie ist. Es gibt **keinen Daemon, keinen +Hintergrundprozess, kein Netzwerk, keine Datenbank und keine +Konfigurationsdateien**. + +## Voraussetzungen + +Ein Linux-System mit: + +- Rust-Toolchain (ab ungefähr Version 1.74) mit `cargo` +- `/run/user/` (wird von systemd bei normalen Sitzungen angelegt). Fehlt + es, verweigern beide Programme mit einer verständlichen Fehlermeldung den + Dienst. +- Nur die Rust-Standardbibliothek: keine externen Crates. + +## Build + +```bash +cargo build --release +``` + +Die Binaries heißen nach dem Build `target/release/copy` und +`target/release/paste`. + +## Installation und Namenskollision mit `paste` + +Beide Binaries sind normale Programme und können in ein Verzeichnis aus `$PATH` +installiert werden, zum Beispiel: + +```bash +install -m 755 target/release/copy ~/.local/bin/copy +install -m 755 target/release/paste ~/.local/bin/paste +``` + +(Wer systemweit installieren will, kopiert stattdessen nach `/usr/local/bin`.) + +**Wichtig:** In den GNU coreutils gibt es bereits ein anderes Programm namens +`paste` (verbindet Zeilen mehrerer Dateien, siehe `man 1 paste`). Diese +Kollision darf nicht verschwiegen werden: + +- Liegt `~/.local/bin` (oder `/usr/local/bin`) in `$PATH` **vor** dem Pfad mit + den coreutils, ruft `paste …` die sjcopy-Variante auf. Prüfen lässt sich das + mit: + + ```bash + which -a paste # zeigt alle Fundstellen in $PATH-Reihenfolge + paste --version # prüft, welche Variante gerade gemeint ist + paste password # sjcopy-Ablage + ``` + +- Ist das nicht gewünscht, obwohl die sjcopy-Binaries installiert wurden, wählt + man eindeutigere Namen, zum Beispiel mit `sj`-Präfix: + + ```bash + mv ~/.local/bin/paste ~/.local/bin/sjpaste + mv ~/.local/bin/copy ~/.local/bin/sjcopy + ``` + + Die Programme sind unabhängig vom Befehlsnamen; Fehlermeldungen nennen ihn + nur in der Ausgabe. Umbenennen ist unproblematisch. + +- Wie bei jedem `$PATH`-Tool gilt: Installiere nur in Verzeichnisse, in die + ausschließlich du selbst schreiben kannst. `/tmp` kommt dafür nicht infrage. + +## Beispiele + +```bash +./generate_password.sh | copy password # Output unter 'password' ablegen +journalctl -b -p err | copy errors # Fehler-Protokoll ablegen +ip addr | copy # ohne Namen: Standardablage 'default' + +paste password # abgelegten Output wieder ausgeben +paste errors +paste # ebenfalls die Standardablage +``` + +Ausgaben von `paste` lassen sich direkt weiterverarbeiten: + +```bash +paste errors | grep -i network +paste password | wc -c +paste build-output > output.bin +``` + +`copy` gibt bei Erfolg keine Meldung aus; `paste` gibt ausschließlich die Daten +selbst nach stdout aus, damit Pipelines bytegenau funktionieren. Fehlermeldungen +beider Programme erscheinen auf stderr. + +## Standardablage + +Wird kein Name übergeben, verwendet sowohl `copy` als auch `paste` die Ablage +`default`. Sie ist eine ganz normale Ablage und wird ebenso überschrieben wie +benannte Ablagen: + +```bash +ip addr | copy # legt in /run/user/$UID/sjcopy/default ab +paste # gibt genau diese Datei aus +``` + +## Temporärer Speicherort + +- Daten liegen unter `/run/user//sjcopy/`, in einer Datei je Ablage + (`default`, `password`, `errors`, `build-output`, …). +- Die aktuelle Benutzer-ID wird über `/proc/self/status` ermittelt, nicht über + Umgebungsvariablen wie `$USER` oder `$XDG_RUNTIME_DIR`. +- Das Verzeichnis `sjcopy/` wird bei Bedarf mit Modus `0700` angelegt, Dateien + mit Modus `0600`. +- Das Werkzeug benutzt ausschließlich dieses eine Verzeichnis; es schreibt + nichts in Home-Verzeichnisse oder andere persistente Orte und löscht + keine fremden Dateien in `/run/user/`. +- Nach dem Neustart (sobald `/run/user/` geleert wird) sind alle Ablagen + weg. + +## Sicherheitshinweise + +- Das Schreiben ist **atomar**: In eine temporäre Datei im selben Verzeichnis + schreiben, dann `rename(2)`. Ein unterbrochener `copy` hinterlässt keine + teilweise überschriebene, zuvor gültige Ablage. +- Temporäre Dateien werden mit exklusivem Anlegen (`O_CREAT | O_EXCL`) und + Modus `0600` erzeugt; Namenskollisionen und Symlink-Angriffe sind dadurch + ausgeschlossen. +- Beim Lesen wird eine Ablage abgelehnt, wenn sie ein symbolischer Link ist; + das Verzeichnis `/run/user/` wird nur benutzt, wenn es ein echtes + Verzeichnis ist und der realen Benutzer-ID gehört. +- Ablagen sind wegen Modus `0600` nur für die aktuelle UID lesbar, aber + **unverschlüsselt**, solange die Sitzung läuft. Sehr vertrauliche Inhalte + gehören nicht dorthin. +- `sudo copy …` liest die reale UID aus `/proc/self/status`; unter `sudo` + (reale UID 0 statt der Sitzungs-UID) würde das Programm die Ablagen von Root + in `/run/user/0` verwalten. Führe das Werkzeug lieber in der eigentlichen + Benutzersitzung aus. + +## Bekannte Einschränkungen + +- **Pipeline-Exit-Codes:** `copy` kann nicht zuverlässig erkennen, ob der + vorherige Befehl in der Pipeline erfolgreich war. Wird Output eines + fehlgeschlagenen Befehls geliefert, legt `copy` ihn trotzdem ab: + + ```bash + ./generate_password.sh | copy password # copy kennt den Status nicht + ``` + + Wer den Gesamtstatus der Pipeline braucht, setzt in der Bash + `set -o pipefail` oder prüft Exit-Codes Schritt für Schritt. `sjcopy` + versucht bewusst nicht, den Status des Vorgängerprozesses zu erraten. + +- Ablagen leben bis zum Neustart oder bis zum Aufräumen von `/run/user/`. + Es gibt kein automatisches Verfall-Datum pro Ablage. +- Die Standardbibliothek lässt `SIGPIPE` ignorieren: Schließt der Empfänger + die Pipeline vorzeitig (zum Beispiel `paste big | head -c 10`), meldet + `paste` einen Schreibfehler (`EPIPE`) auf stderr und bricht mit Exit-Code 1 + ab, statt an einem Signal zu sterben. Die Daten sind bis dahin korrekt + übertragen worden. +- `copy` speichert keine Metadaten (kein Zeitstempel, keine Liste aller + Ablagen); `ls /run/user/$UID/sjcopy/` dient zur Übersicht. +- Namen sind bewusst auf ASCII-Buchstaben, Ziffern, `-` und `_` beschränkt; + `.`, `..` und alles mit Pfadtrennern ist unzulässig. +- Nicht-Linux-Systeme (macOS, BSD, Windows) sind außerhalb des Ziels. diff --git a/sjcopy/sjcopy/src/bin/copy.rs b/sjcopy/sjcopy/src/bin/copy.rs new file mode 100644 index 0000000..cbba756 --- /dev/null +++ b/sjcopy/sjcopy/src/bin/copy.rs @@ -0,0 +1,25 @@ +//! `copy`: Terminal-Output unter einem Namen temporär ablegen. +//! +//! Aufruf: `copy [NAME]` + +use std::env; +use std::io::Read; + +fn main() { + if let Err(err) = run() { + eprintln!("copy: {err}"); + std::process::exit(err.exit_code()); + } +} + +fn run() -> Result<(), sjcopy::Error> { + let name = sjcopy::parse_args(env::args_os(), "copy")?; + let mut data = Vec::new(); + std::io::stdin() + .lock() + .read_to_end(&mut data) + .map_err(|e| sjcopy::Error::io("stdin konnte nicht gelesen werden", e))?; + let dir = sjcopy::runtime_dir()?; + sjcopy::store(&dir, &name, &data)?; + Ok(()) +} diff --git a/sjcopy/sjcopy/src/bin/paste.rs b/sjcopy/sjcopy/src/bin/paste.rs new file mode 100644 index 0000000..cc5b34b --- /dev/null +++ b/sjcopy/sjcopy/src/bin/paste.rs @@ -0,0 +1,22 @@ +//! `paste`: Eine temporär abgelegte Ablage bytegenau nach stdout ausgeben. +//! +//! Aufruf: `paste [NAME]` + +use std::env; +use std::io; + +fn main() { + if let Err(err) = run() { + eprintln!("paste: {err}"); + std::process::exit(err.exit_code()); + } +} + +fn run() -> Result<(), sjcopy::Error> { + let name = sjcopy::parse_args(env::args_os(), "paste")?; + let dir = sjcopy::runtime_dir()?; + let stdout = io::stdout(); + let mut out = stdout.lock(); + sjcopy::load_to(&dir, &name, &mut out)?; + Ok(()) +} diff --git a/sjcopy/sjcopy/src/lib.rs b/sjcopy/sjcopy/src/lib.rs new file mode 100644 index 0000000..c9f972c --- /dev/null +++ b/sjcopy/sjcopy/src/lib.rs @@ -0,0 +1,619 @@ +//! Gemeinsame Logik der beiden Binaries `copy` und `paste`. +//! +//! Beide Programme benutzen dieselbe winzige Bibliothek, bleiben aber +//! eigenständige Binaries ohne gemeinsamen Dispatcher. + +use std::ffi::OsString; +use std::fmt; +use std::fs; +use std::io::{self, Read, Write}; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Standardablage, wenn kein Name übergeben wird. +pub const DEFAULT_NAME: &str = "default"; + +const RUNTIME_BASE: &str = "/run/user"; +const STORAGE_SUBDIR: &str = "sjcopy"; + +/// Restriktive Berechtigungen: Ablageverzeichnis `0700`, Dateien `0600`. +const DIR_MODE: u32 = 0o700; +const FILE_MODE: u32 = 0o600; + +/// Anzahl der Versuche, einen freien Namen für die temporäre Datei zu finden. +const TMP_ATTEMPTS: usize = 8; + +/// Fehlerkinds von `sjcopy`; jede Variante garantiert einen Exit-Code ungleich 0. +#[derive(Debug)] +pub enum Error { + /// Falsche Kommandozeilen-Verwendung. + Usage(String), + /// Ungültiger Ablagename. + Name(String), + /// Laufzeitverzeichnis fehlt oder ist nicht sicher verwendbar. + Runtime(String), + /// Die gewünschte Ablage existiert nicht. + NotFound(String), + /// Sonstiger Ein-/Ausgabefehler mit Kontextangabe. + Io { context: String, source: io::Error }, +} + +impl Error { + /// Exit-Code für das jeweilige Fehlerbild (immer ungleich 0). + pub fn exit_code(&self) -> i32 { + match self { + Error::Usage(_) | Error::Name(_) => 2, + _ => 1, + } + } + + pub fn io(context: impl Into, source: io::Error) -> Error { + Error::Io { + context: context.into(), + source, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Usage(msg) | Error::Name(msg) | Error::Runtime(msg) | Error::NotFound(msg) => { + write!(f, "{msg}") + } + Error::Io { context, source } => write!(f, "{context}: {source}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Io { source, .. } => Some(source), + _ => None, + } + } +} + +/// Validiert einen Ablagenamen. +/// +/// Erlaubt sind nur ASCII-Buchstaben, Ziffern, Bindestrich und Unterstrich. +/// Somit sind leere Namen, `.` und `..`, Pfadtrenner und Pfad-Traversal +/// automatisch ausgeschlossen. +pub fn validate_name(name: &str) -> Result<(), Error> { + if name.is_empty() { + return Err(Error::Name( + "Der Ablagename darf nicht leer sein.".to_string(), + )); + } + if name == "." || name == ".." { + return Err(Error::Name(format!( + "Ungültiger Ablagename {name:?}: `.` und `..` sind nicht erlaubt." + ))); + } + if !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err(Error::Name(format!( + "Ungültiger Ablagename {name:?}: erlaubt sind nur ASCII-Buchstaben, \ + Ziffern, Bindestrich und Unterstrich (. / .. und Pfadangaben sind nicht erlaubt)." + ))); + } + Ok(()) +} + +/// Wertet die Kommandozeilen-Argumente aus: kein Argument liefert `default`, +/// genau ein Argument den Namen; mehr als ein Argument ist ein Nutzungsfehler. +pub fn parse_args(args: I, program: &str) -> Result +where + I: IntoIterator, +{ + let mut iter = args.into_iter(); + let _program_name = iter.next(); + let first = iter.next(); + if iter.next().is_some() { + return Err(Error::Usage(format!( + "Zu viele Argumente.\nAufruf: {program} [NAME]" + ))); + } + match first { + None => Ok(DEFAULT_NAME.to_string()), + Some(arg) => match arg.into_string() { + Ok(name) => { + validate_name(&name)?; + Ok(name) + } + Err(_) => Err(Error::Usage( + "Argumente müssen gültiges UTF-8 enthalten.".to_string(), + )), + }, + } +} + +/// Liest die reale Benutzer-ID aus `/proc/self/status` (nicht aus Umgebungsvariablen). +fn real_uid() -> Result { + let status = fs::read_to_string("/proc/self/status").map_err(|e| { + Error::Runtime(format!( + "Die aktuelle Benutzer-ID konnte nicht ermittelt werden \ + (/proc/self/status nicht lesbar): {e}" + )) + })?; + for line in status.lines() { + if let Some(rest) = line.strip_prefix("Uid:") { + let field = rest.split_whitespace().next().ok_or_else(|| { + Error::Runtime( + "Unerwartetes Format der Uid-Zeile in /proc/self/status.".to_string(), + ) + })?; + return field.parse::().map_err(|_| { + Error::Runtime( + "Unerwartetes Format der Uid-Zeile in /proc/self/status.".to_string(), + ) + }); + } + } + Err(Error::Runtime( + "Keine Uid-Zeile in /proc/self/status gefunden.".to_string(), + )) +} + +/// Ermittelt das vorgesehene Ablageverzeichnis `/run/user/$UID/sjcopy/`, +/// ohne es zu erstellen. Das Laufzeitverzeichnis `/run/user/$UID` muss +/// existieren, ein echtes Verzeichnis sein und dem aktuellen Benutzer gehören. +pub fn runtime_dir() -> Result { + let uid = real_uid()?; + let base = PathBuf::from(format!("{RUNTIME_BASE}/{uid}")); + let md = fs::symlink_metadata(base.as_path()).map_err(|e| { + Error::Runtime(format!( + "Das Laufzeitverzeichnis {} existiert nicht oder ist nicht zugreifbar ({e}).", + base.display() + )) + })?; + if !md.is_dir() { + // `symlink_metadata` folgt dem Link nicht: Ein Symlink ist hier ein Fehler. + return Err(Error::Runtime(format!( + "{} ist kein Verzeichnis; das Laufzeitverzeichnis ist nicht sicher verwendbar.", + base.display() + ))); + } + if md.uid() != uid { + return Err(Error::Runtime(format!( + "{} gehört nicht der aktuellen Benutzer-ID {uid}; \ + aus Sicherheitsgründen wird es nicht verwendet.", + base.display() + ))); + } + Ok(base.join(STORAGE_SUBDIR)) +} + +/// Erstellt `dir` bei Bedarf mit Modus `0700` und erzwingt diese Berechtigungen, +/// falls es bereits existiert. Verweigert Symlinks und Nicht-Verzeichnisse. +pub fn ensure_dir(dir: &Path) -> Result<(), Error> { + match fs::symlink_metadata(dir) { + Ok(md) => { + if !md.is_dir() { + return Err(Error::Runtime(format!( + "{} existiert bereits, ist aber kein Verzeichnis. \ + Aus Sicherheitsgründen wird es nicht verwendet.", + dir.display() + ))); + } + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { + fs::create_dir(dir).map_err(|e| { + Error::io( + format!( + "Ablageverzeichnis {} konnte nicht erstellt werden", + dir.display() + ), + e, + ) + })?; + } + Err(e) => { + return Err(Error::io( + format!( + "Ablageverzeichnis {} konnte nicht geprüft werden", + dir.display() + ), + e, + )); + } + } + fs::set_permissions(dir, fs::Permissions::from_mode(DIR_MODE)).map_err(|e| { + Error::io( + format!( + "Berechtigungen von {} konnten nicht auf 0700 gesetzt werden", + dir.display() + ), + e, + ) + })?; + Ok(()) +} + +/// Liest 8 Bytes Zufall für ein eindeutiges Suffix temporärer Dateien. +fn random_suffix() -> String { + const HEX: &[u8] = b"0123456789abcdef"; + let mut buf = [0u8; 8]; + if let Ok(mut urandom) = fs::File::open("/dev/urandom") { + if urandom.read_exact(&mut buf).is_ok() { + let mut suffix = String::with_capacity(16); + for byte in buf { + suffix.push(HEX[(byte >> 4) as usize] as char); + suffix.push(HEX[(byte & 0xF) as usize] as char); + } + return suffix; + } + } + // Fallback, falls /dev/urandom nicht zur Verfügung steht: Prozess-ID plus + // Nanosekunden-Zeitstempel; kombiniert mit `create_new` trotzdem kollisionsfrei. + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{:x}-{:x}", std::process::id(), nanos) +} + +/// Schreibt die Daten in die bereits geöffnete temporäre Datei und benennt sie +/// atomar auf den Zielnamen um. +fn write_and_rename( + file: &mut fs::File, + tmp: &Path, + target: &Path, + data: &[u8], +) -> Result<(), Error> { + // Berechtigungen unabhängig von einer evtl. großzügigen Umask exakt auf 0600 setzen. + fs::set_permissions(tmp, fs::Permissions::from_mode(FILE_MODE)).map_err(|e| { + Error::io( + format!( + "Temporäre Datei {} konnte nicht auf Modus 0600 gesetzt werden", + tmp.display() + ), + e, + ) + })?; + file.write_all(data).map_err(|e| { + Error::io( + "Daten konnten nicht in die temporäre Ablage geschrieben werden", + e, + ) + })?; + file.flush() + .map_err(|e| Error::io("Temporäre Ablage konnte nicht geleert werden", e))?; + file.sync_data() + .map_err(|e| Error::io("Temporäre Ablage konnte nicht synchronisiert werden", e))?; + // `rename(2)` ersetzt das Ziel atomar; ein eventuell vorhandener Symlink + // am Ziel wird dabei als Link ersetzt, nicht über ihn hinweg geschrieben. + fs::rename(tmp, target).map_err(|e| { + Error::io( + format!( + "Ablage {} konnte nicht atomar ersetzt werden", + target.display() + ), + e, + ) + })?; + Ok(()) +} + +/// Speichert `data` unter dem Namen `name` innerhalb von `dir` (Ablageverzeichnis), +/// atomar über eine temporäre Datei im selben Verzeichnis. +pub fn store(dir: &Path, name: &str, data: &[u8]) -> Result<(), Error> { + validate_name(name)?; + ensure_dir(dir)?; + let target = dir.join(name); + + let mut last_exists: Option = None; + for _ in 0..TMP_ATTEMPTS { + let tmp = dir.join(format!(".{}.tmp-{}", name, random_suffix())); + let open = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(FILE_MODE) + .open(tmp.as_path()); + match open { + Ok(mut file) => { + let result = write_and_rename(&mut file, &tmp, &target, data); + if result.is_err() { + // Temporäre Datei bei gewöhnlichen Fehlern entfernen, damit + // keine zu teilweise gültigen Ablagen verwechslbaren Reste + // im Verzeichnis liegen. + let _ = fs::remove_file(&tmp); + } + return result; + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + last_exists = Some(e); + } + Err(e) => { + return Err(Error::io( + format!( + "Temporäre Datei {} konnte nicht erstellt werden", + tmp.display() + ), + e, + )); + } + } + } + Err(Error::Io { + context: format!( + "Nach {TMP_ATTEMPTS} Versuchen konnte kein freier Name für eine \ + temporäre Datei in {} gefunden werden", + dir.display() + ), + source: last_exists.unwrap_or_else(|| io::Error::other("collisions")), + }) +} + +/// Schreibt den Inhalt der Ablage `name` bytegenau nach `out`. +pub fn load_to(dir: &Path, name: &str, out: &mut W) -> Result<(), Error> { + validate_name(name)?; + let path = dir.join(name); + // Bestehende Symlinks ablehnen: In einer Ablagedatei mit Modus 0600 in einem + // 0700-Verzeichnis kann nur die real gleiche UID geliefert haben; ein Link + // dorthin wäre nichts Eigenes und wird aus Sicherheitsgründen verweigert. + if let Ok(md) = fs::symlink_metadata(&path) { + if md.file_type().is_symlink() { + return Err(Error::Name(format!( + "Die Ablage {} ist ein symbolischer Link; \ + aus Sicherheitsgründen wird sie nicht gelesen.", + path.display() + ))); + } + } + let mut file = fs::File::open(&path).map_err(|e| match e.kind() { + io::ErrorKind::NotFound => Error::NotFound(format!( + "Die Ablage '{name}' ist nicht vorhanden (Verzeichnis: {}).", + dir.display() + )), + io::ErrorKind::PermissionDenied => Error::io( + format!("Keine Leseberechtigung für die Ablage {}", path.display()), + e, + ), + _ => Error::io( + format!("Ablage {} konnte nicht geöffnet werden", path.display()), + e, + ), + })?; + io::copy(&mut file, out).map_err(|e| Error::io("Daten konnten nicht geschrieben werden", e))?; + out.flush() + .map_err(|e| Error::io("Daten konnten nicht geschrieben werden", e))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Isoliertes temporäres Verzeichnis für Tests; überschreibt keine + /// bestehenden Nutzdaten (das echte Laufzeitverzeichnis wird nicht berührt). + struct TempDir { + path: PathBuf, + } + + impl TempDir { + fn new() -> TempDir { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("sjcopy-test-{}-{}", std::process::id(), n)); + fs::create_dir(&path).unwrap(); + TempDir { path } + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn load(dir: &Path, name: &str) -> Result, Error> { + let mut buf = Vec::new(); + load_to(dir, name, &mut buf)?; + Ok(buf) + } + + fn file_mode(path: &Path) -> u32 { + fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777 + } + + fn dir_entries(dir: &Path) -> Vec { + let mut names: Vec = fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names + } + + #[test] + fn roundtrip_text() { + let tmp = TempDir::new(); + let data = b"Hallo, Welt!\nZweite Zeile\n"; + store(&tmp.path, "greeting", data).unwrap(); + assert_eq!(load(&tmp.path, "greeting").unwrap(), data); + } + + #[test] + fn empty_content() { + let tmp = TempDir::new(); + store(&tmp.path, "empty", b"").unwrap(); + assert!(load(&tmp.path, "empty").unwrap().is_empty()); + } + + #[test] + fn preserves_trailing_and_multiple_newlines() { + let tmp = TempDir::new(); + for (name, data) in [ + ("nl1", &b"eine Zeile\n"[..]), + ("nl2", &b"mehrere\n\n\nZeilen\n\n\n\n"[..]), + ("nl3", &b"abschliessende\n\n"[..]), + ] { + store(&tmp.path, name, data).unwrap(); + assert_eq!(load(&tmp.path, name).unwrap(), data); + } + } + + #[test] + fn preserves_binary_data() { + let tmp = TempDir::new(); + let data: Vec = (0..=255_u8).chain([0, 0, 10, 13, 0]).collect(); + store(&tmp.path, "bin", &data).unwrap(); + assert_eq!(load(&tmp.path, "bin").unwrap(), data); + } + + #[test] + fn separate_names_stay_separate() { + let tmp = TempDir::new(); + store(&tmp.path, "alpha", b"A").unwrap(); + store(&tmp.path, "beta", b"B").unwrap(); + assert_eq!(load(&tmp.path, "alpha").unwrap(), b"A"); + assert_eq!(load(&tmp.path, "beta").unwrap(), b"B"); + } + + #[test] + fn overwrite_replaces_completely_and_leaves_no_temp_files() { + let tmp = TempDir::new(); + store(&tmp.path, "doc", b"ganz alter langer Inhalt\n").unwrap(); + store(&tmp.path, "doc", b"neu").unwrap(); + assert_eq!(load(&tmp.path, "doc").unwrap(), b"neu"); + assert_eq!(dir_entries(&tmp.path), vec!["doc".to_string()]); + } + + #[test] + fn default_name_is_used_as_file_name() { + let tmp = TempDir::new(); + store(&tmp.path, DEFAULT_NAME, b"default data").unwrap(); + assert_eq!(dir_entries(&tmp.path), vec![DEFAULT_NAME.to_string()]); + assert_eq!(load(&tmp.path, DEFAULT_NAME).unwrap(), b"default data"); + } + + #[test] + fn missing_ablage_is_an_error() { + let tmp = TempDir::new(); + let err = load(&tmp.path, "does-not-exist").unwrap_err(); + assert!(matches!(err, Error::NotFound(_))); + assert_eq!(err.exit_code(), 1); + // Auch wenn das gesamte Verzeichnis fehlt. + let err = load(&tmp.path.join("missing"), "x").unwrap_err(); + assert!(matches!(err, Error::NotFound(_))); + } + + #[test] + fn invalid_names_are_rejected() { + for bad in [ + "", + ".", + "..", + "a/b", + "../etc/passwd", + "some..", + "a b", + "päss", + "back\\slash", + "semi;colon", + "unter\x00Null", + ] { + assert!( + validate_name(bad).is_err(), + "Name {bad:?} hätte akzeptiert werden dürfen" + ); + let tmp = TempDir::new(); + assert!(store(&tmp.path, bad, b"x").is_err(), "store({bad:?})"); + // Es wurde keine Datei im Verzeichnis angelegt. + assert!(dir_entries(&tmp.path).is_empty(), "store({bad:?})"); + } + for good in [ + "okay", + "A-Z_a-z0-9", + "123", + "-", + "_", + "errors", + "build-output", + ] { + assert!( + validate_name(good).is_ok(), + "Name {good:?} sollte gültig sein" + ); + } + } + + #[test] + fn argument_count_is_enforced() { + let os = |s: &[&str]| s.iter().map(OsString::from).collect::>(); + assert_eq!(parse_args(os(&["copy"]), "copy").unwrap(), DEFAULT_NAME); + assert_eq!(parse_args(os(&["copy", "pw"]), "copy").unwrap(), "pw"); + assert!(matches!( + parse_args(os(&["copy", "a", "b"]), "copy"), + Err(Error::Usage(_)) + )); + assert_eq!( + parse_args(os(&["paste", "a", "b"]), "paste") + .unwrap_err() + .exit_code(), + 2 + ); + assert!(matches!( + parse_args(os(&["copy", "a/b"]), "copy"), + Err(Error::Name(_)) + )); + } + + #[test] + fn permissions_and_treatment_of_the_directory() { + let tmp = TempDir::new(); + // Unterverzeichnis wird bei Bedarf mit 0700 erstellt. + let dir = tmp.path.join("sjcopy"); + ensure_dir(&dir).unwrap(); + assert_eq!(file_mode(&dir), 0o700); + store(&dir, "sec", b"geheim").unwrap(); + assert_eq!(dir_entries(&dir), vec!["sec".to_string()]); + assert_eq!(file_mode(&dir.join("sec")), 0o600); + // Bestehendes Verzeichnis wird nachträglich auf 0700 gezogen. + fs::set_permissions(&dir, fs::Permissions::from_mode(0o750)).unwrap(); + store(&dir, "sec", b"geheim").unwrap(); + assert_eq!(file_mode(&dir), 0o700); + // Ein bestehender Symlink am Ziel wird beim Schreiben atomar durch + // die neue Datei ersetzt, nicht durch den Link hindurch geschrieben. + let attack = tmp.path.join("elsewhere"); + Command::new("ln") + .args([ + "-s", + "/nonexistent/sjcopy-attack", + &dir.join("linked").to_string_lossy(), + ]) + .status() + .unwrap(); + store(&dir, "linked", b"x").unwrap(); + assert!(!attack.exists()); + // Symlink als Ablage wird beim Lesen abgelehnt. + let honest = tmp.path.join("real"); + fs::write(&honest, b"honest").unwrap(); + Command::new("ln") + .args([ + "-s", + &honest.to_string_lossy(), + &dir.join("alias").to_string_lossy(), + ]) + .status() + .unwrap(); + let err = load(&dir, "alias").unwrap_err(); + assert!(matches!(err, Error::Name(_))); + } + + #[test] + fn runtime_dir_resolves_the_designated_location() { + // Der Test läuft unter derselben realen UID wie das Programm insgesamt. + let uid = real_uid().unwrap(); + let expected = PathBuf::from(format!("/run/user/{uid}/sjcopy")); + assert_eq!(runtime_dir().unwrap(), expected); + } +} diff --git a/statserver/stats.db b/statserver/stats.db index 494b93a..9b1c314 100644 Binary files a/statserver/stats.db and b/statserver/stats.db differ diff --git a/statserver/stats.db-journal b/statserver/stats.db-journal deleted file mode 100644 index 82fb5db..0000000 Binary files a/statserver/stats.db-journal and /dev/null differ