fix(P1): kare başına alt süreçler, eksik i18n anahtarları, boş kullanıcı adı, GeoIP dil hatası

## 1. Kare başına alt süreç (3 yer)
  - installer.rs: WelcomeStep::show her karede detect_existing_linux()
    çağırıyordu → blkid -o list, yani saniyede ~60 tam blok aygıt
    taraması. Sonuç rescue_available alanında bir kez hesaplanıyor.
  - steps/network.rs: `nmcli dev status` show() içinde, kare başına.
    status_text alanına alındı; on_enter'da ve tarama/bağlanma
    sonrasında tazeleniyor.

## 2. Eksik çeviri anahtarları
  - mp_duplicate_mountpoint: iki locale'de de YOKTU → 8633422'de
    eklenen duplicate mountpoint kontrolü hata durumunda ekranda
    çıplak anahtar gösteriyordu
  - job_netinstall, job_setup_lvm: en.toml'da YOKTU → İngilizce
    kurulumda çıplak anahtar
  - username_empty: aşağıdaki boş kullanıcı adı düzeltmesi için
Doğrulama: 274 kullanılan anahtarın her ikisinde de karşılığı var
(0 eksik). job_* anahtarları t!(job.name()) ile dolaylı kullanılıyor,
doğrulama betiği bunları göremiyor ama mevcut.

## 3. Boş kullanıcı adı doğrulamadan geçiyordu (steps/users.rs)
validate_username("") None (hata yok) dönüyordu. Geçerli bir hostname
ile boş kullanıcı adı sihirbazı geçip CreateUserJob'a ulaşıyor ve
`useradd ""` çalıştırıyordu. Artık hata dönüyor.
username_empty anahtarı iki locale'e de eklendi.

## 4. GeoIP var olmayan dillere geçiyordu (steps/location.rs)
countryCode eşlemesi 8 ülkeyi (de/fr/it/es/ru/nl/pl) 8 dile
bağlıyordu ama locales/ altında yalnızca tr.toml ve en.toml var.
set_locale("de") hata vermez, sessizce kabul edilir ve t!() karşılığı
olmayan her anahtar için anahtarın KENDİSİNİ döndürür — Alman
kullanıcı tüm arayüzü "location_title", "next", "partition" gibi
çıplak metinlerle görüyordu.

  - language_for_country() yalnızca gerçekten var olan dilleri döndürüyor
  - funct::sanitize_locale() + AVAILABLE_LANGUAGES: set_locale
    çağrılarından ÖNCE koruma. Desteklenmeyen kod sessizce yedeğe
    düşüyor. 3 çağrı da korumaya alındı.
  - ZA (Güney Afrika) bilinçli olarak listede değil: 11 resmî dili var

## Testler
steps/users.rs ve steps/location.rs ve funct.rs'te test modu yoktu.
Eklenenler:
  users   : 8 test (boş ad, geçerli adlar, yasaklı sistem adları,
            başlangıç/karakter kuralları, hostname kuralları,
            Türkçe transliterasyon, boş dönmezlik, uzunluk)
  location: 4 test (her eşlemenin karşılığı olan locale dosyasına
            sahip olduğunu doğrular — asıl regresyon testi)
  funct   : 3 test (locale koruması + yedeğin kendisi destekleniyor)

cargo check --all-targets : 0 hata, 0 uyarı
cargo test              : 43/43 geçti (28 → 43)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erkan IŞIK
2026-09-25 22:34:05 +03:00
parent d161556c73
commit c02d732bef
8 changed files with 288 additions and 32 deletions
+4
View File
@@ -62,6 +62,7 @@ password_confirm_label = "Confirm password"
hostname_label = "Computer name"
username_invalid = "Invalid username. Must start with a lowercase letter or underscore, max 32 characters."
username_too_long = "Username too long (maximum 32 characters)"
username_empty = "Username cannot be empty"
username_forbidden = "This username is reserved by the system and is forbidden"
username_invalid_start = "Username must start with a lowercase letter or underscore"
username_invalid_chars = "Only lowercase letters, digits, underscore and hyphen are allowed"
@@ -146,6 +147,7 @@ mp_create_here = "Create Here"
mp_will_delete = "will be deleted"
mp_restore = "Restore"
mp_free_space = "Free Space"
mp_duplicate_mountpoint = "Two partitions share the same mount point; each partition must have a distinct mount point"
mp_bar_now = "Current Layout"
mp_bar_after = "After Installation"
@@ -216,6 +218,8 @@ job_install_packages = "Installing selected packages"
job_ldconfig = "Configuring shared libraries (ldconfig)"
job_update_env = "Updating system environment"
job_cleanup_live = "Cleaning up live environment traces"
job_netinstall = "Installing extra packages"
job_setup_lvm = "Configuring LVM"
# ── Manual Partition UI ────────────────────────────────────
manual_partition_title = "Manual Partitioning"
+2
View File
@@ -62,6 +62,7 @@ password_confirm_label = "Parola (tekrar)"
hostname_label = "Bilgisayar adı"
username_invalid = "Geçersiz kullanıcı adı. Küçük harf veya alt çizgi ile başlamalı, en fazla 32 karakter."
username_too_long = "Kullanıcı adı çok uzun (maksimum 32 karakter)"
username_empty = "Kullanıcı adı boş olamaz"
username_forbidden = "Bu kullanıcı adı sistem tarafından kullanılıyor ve yasaklı"
username_invalid_start = "Kullanıcı adı küçük harf veya alt çizgi ile başlamalı"
username_invalid_chars = "Sadece küçük harf, rakam, alt çizgi ve tire kullanılabilir"
@@ -122,6 +123,7 @@ mp_create_here = "Buraya Oluştur"
mp_will_delete = "silinecek"
mp_restore = "Geri Al"
mp_free_space = "Boş Alan"
mp_duplicate_mountpoint = "Aynı bağlama noktası iki bölüme birden verilmiş; her bölümün farklı bir bağlama noktası olmalı"
mp_bar_now = "Mevcut Durum"
mp_bar_after = "Kurulum Sonrası"
+56 -1
View File
@@ -1,5 +1,28 @@
use eframe::egui;
/// Uygulamanın desteklediği diller (`locales/` altındaki dosyalarla eşleşir).
///
/// `locales/` klasörüne yeni bir dosya eklendiğinde buraya da eklenmelidir.
pub const AVAILABLE_LANGUAGES: &[&str] = &["tr", "en"];
/// Yedek dil: desteklenmeyen bir kod gelirse kullanılır.
pub const FALLBACK_LANGUAGE: &str = "en";
/// `rust_i18n::set_locale` çağrısından ÖNCE kullanılır.
///
/// Neden: `set_locale("de")` hata vermez, sessizce kabul eder ve `t!()`
/// karşılığı olmayan her anahtar için anahtarın KENDİSİNİ döndürür —
/// arayüz "location_title", "next", "partition" gibi çıplak metinlere
/// dolar. O yüzden karar burada verilir: dil desteklenmiyorsa hiç
/// değiştirilmez, kullanıcının seçimi korunur.
pub fn sanitize_locale(locale: &str) -> &str {
if AVAILABLE_LANGUAGES.contains(&locale) {
locale
} else {
FALLBACK_LANGUAGE
}
}
#[macro_export]
macro_rules! draw_icon {
($ui:expr, $path:literal, $size:expr, $color:expr) => {
@@ -150,4 +173,36 @@ pub fn language_selector_ui(ui: &mut eframe::egui::Ui, state: &mut crate::instal
}
});
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn supported_locales_pass_through() {
assert_eq!(sanitize_locale("tr"), "tr");
assert_eq!(sanitize_locale("en"), "en");
}
#[test]
fn unsupported_locales_fall_back() {
// Bu diller için locale dosyası YOK. set_locage'e sızsaydı
// arayüz çıplak anahtarlarla dolardı.
for code in ["de", "fr", "it", "es", "ru", "nl", "pl", "xx", ""] {
assert_eq!(
sanitize_locale(code),
FALLBACK_LANGUAGE,
"{} desteklenmiyor, yedeğe düşmeli", code
);
}
}
#[test]
fn fallback_language_is_itself_supported() {
// Yedeğin de desteklenmemesi durumunda sonsuz hata durumu oluşur.
assert!(
AVAILABLE_LANGUAGES.contains(&FALLBACK_LANGUAGE),
"yedek dil {} desteklenen listede olmalı", FALLBACK_LANGUAGE
);
}
}
+17 -4
View File
@@ -399,6 +399,9 @@ pub struct WelcomeStep {
checks: Vec<CheckResult>,
checked: bool,
last_language: String,
/// Kurtarma modunun kullanılabilir olup olmadığı. `on_enter`'da bir kez
/// hesaplanır — `blkid` her karede çalıştırılırsa UI kilitlenir.
rescue_available: bool,
}
impl Default for WelcomeStep {
@@ -407,6 +410,7 @@ impl Default for WelcomeStep {
checks: Vec::new(),
checked: false,
last_language: String::new(),
rescue_available: false,
}
}
}
@@ -416,8 +420,9 @@ impl InstallerStep for WelcomeStep {
fn on_enter(&mut self, state: &mut GlobalState) {
if !self.checked || self.last_language != state.language {
rust_i18n::set_locale(&state.language);
rust_i18n::set_locale(crate::funct::sanitize_locale(&state.language));
self.checks = run_all();
self.rescue_available = detect_existing_linux();
self.checked = true;
self.last_language = state.language.clone();
}
@@ -426,9 +431,13 @@ impl InstallerStep for WelcomeStep {
fn show(&mut self, ui: &mut egui::Ui, state: &mut GlobalState) {
use crate::ui::theme;
// on_enter ile aynı koşul, aynı etki: dil değiştiyse kontrolleri ve
// kurtarma kullanılabilirliğini tazele. (show() içinde yalnızca bu
// senkron blok var; artık disk taraması yapmıyor.)
if self.last_language != state.language {
rust_i18n::set_locale(&state.language);
rust_i18n::set_locale(crate::funct::sanitize_locale(&state.language));
self.checks = run_all();
self.rescue_available = detect_existing_linux();
self.last_language = state.language.clone();
}
@@ -568,8 +577,12 @@ impl InstallerStep for WelcomeStep {
.inner_margin(egui::Margin::symmetric(8.0, 4.0))
.show(ui, |ui| {
ui.horizontal(|ui| {
let rescue_available = detect_existing_linux();
if rescue_available {
// `detect_existing_linux()` bir `blkid -o list` süreci
// başlatır — TÜM blok aygıtlarını tarar. Bunu çizim
// (show) içinde çağırmak saniyede ~60 kez süreç
// doğuruyor, yani kare hızında tam disk taraması.
// Sonuç `on_enter`'da bir kez hesaplanıp saklanıyor.
if self.rescue_available {
if theme::light_gray_btn(ui, &t!("rescue_button")).clicked() {
state.rescue_mode = true;
}
+1 -1
View File
@@ -295,7 +295,7 @@ impl Default for YaliApp {
impl eframe::App for YaliApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
rust_i18n::set_locale(&self.state.language);
rust_i18n::set_locale(crate::funct::sanitize_locale(&self.state.language));
// Dil değişimi kontrolü -> branding dosyasını dinamik olarak yeniden yükle
if self.loaded_language != self.state.language {
+80 -10
View File
@@ -97,6 +97,31 @@ impl Default for LocationStep {
}
impl LocationStep {
/// Ülke kodundan dil koduna çevirir.
///
/// YALIDIR: yalnızca `locales/` altında GERÇEKTEN bulunan diller
/// döndürür. Daha önce 8 ülke eşleniyordu (de/fr/it/es/ru/nl/pl) ama
/// projede yalnızca tr.toml ve en.toml var. `set_locale("de")`
/// çağrıldığında rust-i18n sessizce anahtarın kendisini döndürür —
/// yani Alman kullanıcı "location_title", "next", "partition" gibi
/// çıplak anahtarlarla dolu bir arayüz görüyordu.
fn language_for_country(country: &str) -> Option<&'static str> {
match country {
"TR" => Some("tr"),
// AB'nin İngilizce konuşulan bölümleri ve İngilizce resmî
// dili olan ülkeler. Bunlar en.toml'a düşer.
//
// Not: Güney Afrika (ZA) bilinçli olarak listede değil — 11
// resmî dili var ve İngilizce baskın değil. Benzer durumdaki
// ülkeler de (Kanada, İsviçre) yalnızca İngilizce resmî dile
// sahip oldukları için listede.
"GB" | "IE" | "MT" | "US" | "CA" | "AU" | "NZ" | "IN" | "SG" |
"NG" | "KE" | "GH" | "HK" | "JM" | "BS" | "BB" |
"BZ" | "GY" | "TT" | "BW" | "ZW" | "ZM" | "FJI" => Some("en"),
_ => None,
}
}
fn geoip_lookup(state: &mut GlobalState) {
let out = std::process::Command::new("curl")
.args(["-s", "--connect-timeout", "3", "http://ip-api.com/json"])
@@ -111,16 +136,10 @@ impl LocationStep {
})
};
if let Some(country) = extract("countryCode") {
match country.as_str() {
"TR" => state.language = "tr".to_string(),
"DE" => state.language = "de".to_string(),
"FR" => state.language = "fr".to_string(),
"IT" => state.language = "it".to_string(),
"ES" => state.language = "es".to_string(),
"RU" => state.language = "ru".to_string(),
"NL" => state.language = "nl".to_string(),
"PL" => state.language = "pl".to_string(),
_ => {}
// Mevcut locale dosyası olmayan bir dile geçilmezse
// kullanıcının seçtiği dil korunur.
if let Some(lang) = Self::language_for_country(&country) {
state.language = lang.to_string();
}
}
if let Some(tz) = extract("timezone") {
@@ -347,3 +366,54 @@ impl InstallerStep for LocationStep {
!state.timezone.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn geoip_only_maps_to_locales_that_exist() {
// Regresyon: de/fr/it/es/ru/nl/pl eşleniyordu ama locales/ altında
// yalnızca tr.toml ve en.toml var. set_locale("de") sessizce
// kabul edildiği için Alman kullanıcı tüm arayüzü çıplak
// anahtar olarak görüyordu.
for code in ["DE", "FR", "IT", "ES", "RU", "NL", "PL"] {
let mapped = LocationStep::language_for_country(code);
if let Some(lang) = mapped {
assert!(
crate::funct::AVAILABLE_LANGUAGES.contains(&lang),
"{} -> {} eşlendi ama {} locale dosyası yok",
code, lang, lang
);
}
}
}
#[test]
fn turkey_maps_to_turkish() {
assert_eq!(LocationStep::language_for_country("TR"), Some("tr"));
}
#[test]
fn english_speaking_countries_map_to_english() {
for code in ["GB", "US", "IE", "AU", "CA", "NZ", "IN"] {
assert_eq!(
LocationStep::language_for_country(code),
Some("en"),
"{} İngilizce'ye eşlenmeli", code
);
}
}
#[test]
fn unknown_countries_leave_language_alone() {
// Eşleme yok → None → kullanıcının seçtiği dil korunur.
for code in ["JP", "CN", "BR", "ZA", "XX", ""] {
assert_eq!(
LocationStep::language_for_country(code),
None,
"{} eşlenmemeli", code
);
}
}
}
+44 -15
View File
@@ -9,6 +9,9 @@ pub struct NetworkStep {
scanning: bool,
scan_done: bool,
proxy_enabled: bool,
/// `nmcli dev status` çıktısı. Adıma girişte bir kez okunur —
/// show() içinde okunursa her karede alt süreç doğar.
status_text: Option<String>,
}
#[derive(Clone, Default)]
@@ -66,6 +69,15 @@ impl NetworkStep {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
/// `nmcli dev status` çıktısını tazeler. Adıma girişte ve Wi-Fi
/// bağlantı denemelerinden sonra çağrılır.
fn refresh_status(&mut self) {
self.status_text = std::process::Command::new("nmcli")
.args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "dev", "status"])
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).into_owned());
}
}
impl InstallerStep for NetworkStep {
@@ -73,28 +85,41 @@ impl InstallerStep for NetworkStep {
t!("network").to_string()
}
fn on_enter(&mut self, _state: &mut GlobalState) {
self.refresh_status();
}
fn show(&mut self, ui: &mut egui::Ui, state: &mut GlobalState) {
ui.heading(t!("network_title"));
ui.label(t!("network_description"));
ui.add_space(12.0);
// Mevcut bağlantı durumu
if let Ok(out) = std::process::Command::new("nmcli")
.args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "dev", "status"])
.output()
{
let text = String::from_utf8_lossy(&out.stdout);
let connected: Vec<&str> = text.lines()
.filter(|l| l.contains(":connected:") || l.contains(":connecting:"))
.collect();
if !connected.is_empty() {
//
// `nmcli dev status` bir alt süreçtir ve ~50-100 ms sürer. Burada
// show() içinde, yani saniyede ~60 kez çağrılıyordu. Durum
// adımlara girişte bir kez okunur; bağlantı değişikliği
// `scan_wifi`/`connect` sonrası elle tazelenir.
match &self.status_text {
Some(text) => {
let connected: Vec<&str> = text.lines()
.filter(|l| l.contains(":connected:") || l.contains(":connecting:"))
.collect();
if !connected.is_empty() {
ui.colored_label(
egui::Color32::from_rgb(80, 170, 80),
format!("{} {}", t!("network_connected"), connected.join(", ")),
);
} else {
ui.colored_label(
egui::Color32::from_rgb(200, 140, 40),
t!("network_disconnected"),
);
}
}
None => {
ui.colored_label(
egui::Color32::from_rgb(80, 170, 80),
format!("{} {}", t!("network_connected"), connected.join(", ")),
);
} else {
ui.colored_label(
egui::Color32::from_rgb(200, 140, 40),
egui::Color32::from_rgb(120, 120, 140),
t!("network_disconnected"),
);
}
@@ -128,6 +153,7 @@ impl InstallerStep for NetworkStep {
if !self.scan_done {
if ui.add(egui::Button::new(t!("network_scan"))).clicked() {
self.scan_wifi();
self.refresh_status();
}
} else {
ui.horizontal(|ui| {
@@ -185,6 +211,9 @@ impl InstallerStep for NetworkStep {
Ok(msg) => {
state.install_log.push(format!("Wi-Fi: {} — {}", net.ssid, msg));
self.scan_done = false;
// Bağlantı durumu değişti; üstteki
// "bağlı/bağlı değil" satırı tazelensin.
self.refresh_status();
}
Err(e) => {
state.install_log.push(format!("Wi-Fi error: {}", e));
+84 -1
View File
@@ -66,7 +66,10 @@ fn derive_username(real_name: &str) -> String {
/// Hata varsa hata mesajını döndürür, yoksa None döndürür.
fn validate_username(s: &str) -> Option<String> {
if s.is_empty() {
return None;
// Boş kullanıcı adı GEÇERLİ DEĞİLDİR. Daha önce burada `None`
// (hata yok) dönüyordu, bu yüzden geçerli bir hostname ile boş
// kullanıcı adı sihirbazı geçip `useradd ""` çalıştırıyordu.
return Some(t!("username_empty").to_string());
}
if s.len() > 32 {
return Some(t!("username_too_long").to_string());
@@ -425,3 +428,83 @@ impl InstallerStep for UsersStep {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_username_is_rejected() {
// Regresyon: validate_username("") None (hata yok) dönüyordu, bu
// yüzden geçerli bir hostname ile boş kullanıcı adı sihirbazı
// geçip CreateUserJob'a ulaşıyordu.
assert!(
validate_username("").is_some(),
"boş kullanıcı adı hata dönmeli"
);
assert!(!is_valid_username(""), "boş kullanıcı adı geçerli olmamalı");
}
#[test]
fn valid_usernames_pass() {
for name in ["user", "ahmet", "a", "_root_", "user-1", "u_2026"] {
assert!(
validate_username(name).is_none(),
"{} geçerli olmalı", name
);
}
}
#[test]
fn system_usernames_are_forbidden() {
for name in ["root", "daemon", "nobody", "sshd", "www-data"] {
assert!(
validate_username(name).is_some(),
"{} yasaklı olmalı", name
);
}
}
#[test]
fn username_start_and_charset_rules() {
assert!(validate_username("1user").is_some(), "rakamla başlayamaz");
assert!(validate_username("Auser").is_some(), "büyük harfle başlayamaz");
assert!(validate_username("user name").is_some(), "boşluk olmaz");
assert!(validate_username(&"a".repeat(33)).is_some(), "32'den uzun olmaz");
assert!(validate_username(&"a".repeat(32)).is_none(), "32 kabul edilir");
}
#[test]
fn hostname_rules() {
assert!(is_valid_hostname("pisi-pc"));
assert!(is_valid_hostname("Pisi1"));
assert!(!is_valid_hostname(""), "boş olmaz");
assert!(!is_valid_hostname("-pisi"), "tire ile başlayamaz");
assert!(!is_valid_hostname("pisi-"), "tire ile bitemez");
assert!(!is_valid_hostname("pisi pc"), "boşluk olmaz");
assert!(!is_valid_hostname("pisi_pc"), "alt çizgi olmaz");
assert!(!is_valid_hostname(&"a".repeat(64)), "63'ten uzun olmaz");
assert!(is_valid_hostname(&"a".repeat(63)), "63 kabul edilir");
}
#[test]
fn derive_username_transliterates_turkish() {
assert_eq!(derive_username("Şükrü Çelik"), "sukru-celik");
assert_eq!(derive_username("Çağrı"), "cagri");
assert_eq!(derive_username("İpek"), "ipek");
}
#[test]
fn derive_username_never_returns_empty() {
// Yalnızca geçersiz karakterlerden oluşan isimde boş dönmemeli.
assert_eq!(derive_username("!!!"), "user");
assert_eq!(derive_username(" "), "user");
assert_eq!(derive_username(""), "user");
}
#[test]
fn derive_username_caps_length() {
let long = "a".repeat(50);
assert_eq!(derive_username(&long).len(), 32);
}
}