diff --git a/AGENTS.md b/AGENTS.md index d0e3b22..1b19736 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -239,6 +239,49 @@ sda1 ile sdb1 çakışmaz. PV'lerinden toplanır — önceden tüm disklerin PV'leri birleştiriliyordu ve boyut toplamı yanlış çıkıyordu (AGENTS.md'deki açık maddeydi). +### ⭐ Mountpoint boşluğu mount EDİLMEMİŞ bölüm demek değil + +`read_existing_partitions` mountpoint'i **`lsblk` MOUNTPOINT sütunundan** +okur (satır ~67: `tokens.next().unwrap_or("")`). Canlı ISO'da mount +edilmemiş her bölümde bu sütun **boştur**. + +**Çift önyüklemeli makinede Windows'un boot bölümü neredeyse her zaman +bu durumdadır** ve kullanıcı ona dokunmamıştır. Buna rağmen +`validate_manual_on` tüm tanımları kapsayan bir "boş mountpoint" +kontrolü içeriyordu → düzen reddediliyordu, hata hangi bölümden +geldiğini söylemeden. + +Kural artık: **boş mountpoint zorunluluğu yalnızca `orig_device` BOŞ +olan (kullanıcı tanımı) bölümlerde geçerli.** Fiziksel taranmış +bölümde boş mountpoint "henüz atanmadı" demektir ve iş zincirinde +zaten güvenli: + +| Aşama | Davranış | +|---|---| +| mount | atlanır (`jobs/partition.rs:1111`) | +| fstab | atlanır (`:1171`) | +| mkfs | atlanır (`skip_format: true`) | + +Aynı sebeple **mükerrer bağlama noktası kontrolü de boş değerleri +dışlar** — aksi hâlde "N atanmamış bölüm" kendi kendine mükerrer +sayılır ve asıl sorun maskelenir. + +### `suggest_mountpoint` — öneri TÜM düzeni görmeli + +`/`, `/boot/efi` ve `swap` bir kurulumda **TEKİLDİR**. Öneri bu +yüzden aktif diske değil **tüm düzene** bakar. + +Aktif diske bakıyordu: kullanıcı boş bir ikinci diske geçip "Yeni +Bölüm" dediğinde o diskte tanım olmadığı için `/boot/efi` öneriliyordu +— sdb1'de zaten var olan mountpoint'i ikinci kez. Kullanıcı +kaydetmeye çalışınca "mükerrer" alıyor, alanı temizleyince "boş +olamaz" alıyordu. İkisi de hiçbir şey söylemiyordu. + +Sıra: `/boot/efi` (UEFI) → `/` → `swap` → `/home` → boş. Son adım +ikincil (veri) diskler içindir: boş diske bölüm eklemek için alanı +elle doldurmak zorunda kalmamak. Silinmek işaretli tanımlar +sayılmaz. + ### `selected_disk` hâlâ 3 yerde kullanılıyor 1. Tablo hangi diski gösteriyor + `manual_dual_bars` 2. Diyaloğun varsayılan hedefi diff --git a/src/steps/partition.rs b/src/steps/partition.rs index 041b422..979ebf8 100644 --- a/src/steps/partition.rs +++ b/src/steps/partition.rs @@ -409,10 +409,21 @@ fn validate_manual_on( } } - // Aynı mountpoint'ta iki bölüm tanımlı mı kontrolü - let mountpoints: std::collections::HashSet<&str> = - live_parts.iter().map(|p| p.mountpoint.as_str()).collect(); - if mountpoints.len() != live_parts.len() { + // Aynı mountpoint'ta iki bölüm tanımlı mı kontrolü. + // + // BOŞ mountpoint'ler ÇİKARILIR: `read_existing_partitions` mountpoint'i + // lsblk'in MOUNTPOINT sütunundan alır ve canlı ISO'da mount EDILMEMİŞ + // bölümlerde bu sütun boştur. Çift önyüklemeli bir makinede Windows'un + // sda1'i neredeyse her zaman boş gelir. Boş değerler karşılaştırılırsa + // "N tane atanmamış bölüm" kendi kendine mükerrer sayılır ve kullanıcı + // var olmayan bir çakışma hatası görürdü. + let mountpoints: std::collections::HashSet<&str> = live_parts + .iter() + .map(|p| p.mountpoint.as_str()) + .filter(|m| !m.is_empty()) + .collect(); + let assigned = live_parts.iter().filter(|p| !p.mountpoint.is_empty()).count(); + if mountpoints.len() != assigned { return Some(t!("mp_duplicate_mountpoint").to_string()); } @@ -426,10 +437,26 @@ fn validate_manual_on( } } - // Mountpoint boş olmamalı — TEK istisna LVM PV'dir (üzerinde - // mantıksal hacimler yaşar, kendisi bağlanmaz). + // Mountpoint boş olmamalı — ama YALNIZCA KULLANICI TANIMLARI için. + // + // REGRESYON: bu kontrol tüm tanımları kapsıyordu. Taranmış fiziksel + // bir bölümün mountpoint'i canlı ISO'da mount edilmediği için BOŞTUR + // (lsblk MOUNTPOINT sütunu). Çift önyüklemeli bir makinede Windows'un + // sda1'i her zaman bu durumdadır ve kullanıcı ona hiç dokunmamıştı — + // yine de düzen reddediliyordu: + // + // "sda: sda1 bozuk değil, sadece mount değil" → hata ✗ + // + // Boş mountpointli bir FİZİKSEL bölüm "henüz atanmadı" demektir ve + // iş zincirinde zaten güvenli: mount edilmez (jobs/partition.rs:1111), + // fstab'a girmez (:1171) ve `skip_format: true` geldiği için + // formatlanmaz da. Yani doğrulama bir şey engellemiyor, sadece + // kullanıcıyı yanlış yönlendiriyordu. + // + // Zorunluluk yeni tanımlarda (orig_device boş) ve LVM PV dışında + // geçerlidir: kullanıcı "ne işe yarayacak" diye sormadan kaydedemez. for p in &live_parts { - if p.mountpoint.is_empty() && p.fstype != FsType::Lvm { + if p.orig_device.is_empty() && p.mountpoint.is_empty() && p.fstype != FsType::Lvm { return Some(t!("mp_error_empty_mountpoint").to_string()); } } @@ -441,6 +468,41 @@ fn validate_manual_on( // Çoklu disk yardımcıları // ───────────────────────────────────────────── +/// "Yeni Bölüm" diyaloğu için varsayılan bağlama noktası önerisi. +/// +/// `/`, `/boot/efi` ve `swap` bir kurulumda **TEKİLDİR** — birdir. Bu +/// yüzden sayım aktif disk DEĞİL, TÜM düzen üzerinden yapılır. +/// +/// REGRESYON: sayım aktif diske bakıyordu. Kullanıcı boş bir ikinci +/// diske geçip "Yeni Bölüm" dediğinde o diskte hiçbir tanım olmadığı +/// için öneri `/boot/efi` çıkıyordu — sdb1'de zaten var olan bir +/// mountpoint'i ikinci kez öneriyordu. Kullanıcı kaydetmeye +/// çalışınca "mükerrer bağlama noktası" hatası alıyor, alanı +/// temizleyince de "boş olamaz" hatası. İkisi de kullanıcıya hiçbir +/// şey söylemiyordu. +/// +/// `/home` adımı ikincil (veri) diskler içindir: kök, EFI ve swap +/// başka yerdeyse sıra ona gelir, böylece boş bir diske bölüm +/// eklemek için alanı elle doldurmak zorunda kalmazsınız. +fn suggest_mountpoint(parts: &[CustomPartition], is_uefi: bool) -> &'static str { + let live: Vec<&CustomPartition> = parts.iter().filter(|p| !p.delete).collect(); + let has_mp = |mp: &str| live.iter().any(|p| p.mountpoint == mp); + let has_fs = |fs: FsType| live.iter().any(|p| p.fstype == fs); + + if is_uefi && !has_mp("/boot/efi") && !has_fs(FsType::Fat32) { + "/boot/efi" + } else if !has_mp("/") { + "/" + } else if !has_fs(FsType::Swap) { + "swap" + } else if !has_mp("/home") { + "/home" + } else { + // Her şey atanmış — kullanıcı elle seçer. + "" + } +} + /// Verilen diskin toplam boyutunu MB cinsinden döndürür. /// Disk listede yoksa 0 döner. fn disk_size_mb(state: &GlobalState, disk: &str) -> u64 { @@ -1453,49 +1515,24 @@ impl PartitionStep { self.manual.adding = true; self.manual.editing = false; self.manual.edit_idx = None; + self.manual.add_encrypt = false; + self.manual.add_luks_password.clear(); + // Yeni tanım SEÇİLİ diske eklenir. Diyalogda ayrı bir // hedef disk seçicisi yok; hedefi değiştirmek için // disk şeridinden önce o diske geçilir. - let active_disk = state.selected_disk.clone().unwrap_or_default(); - // Öneriler bu diskin DURUMUNA göre yapılır: - // - UEFI'de ve henüz EFI yoksa → /boot/efi - // - kök yoksa → / - // - kök varsa ve swap yoksa → swap - // Böylece kullanıcı sdb'ye geçtiğinde öneri de sdb'ye göre - // değişir; daha önce her zaman aktif diskin sayımı - // kullanılıyordu ve yeni diske geçince "/boot/efi" gibi - // alakasız bir öneri çıkıyordu. - let defs: Vec<&CustomPartition> = state - .custom_partitions - .iter() - .filter(|p| p.disk == active_disk && !p.delete) - .collect(); - let has_root = defs.iter().any(|p| p.mountpoint == "/"); - let has_efi = defs.iter().any(|p| { - p.mountpoint == "/boot/efi" || p.fstype == FsType::Fat32 - }); - let has_swap = defs.iter().any(|p| p.fstype == FsType::Swap); - - self.manual.add_mountpoint.clear(); - self.manual.add_encrypt = false; - self.manual.add_luks_password.clear(); - self.manual.add_mountpoint = if self.is_uefi && !has_efi { - "/boot/efi".to_string() - } else if !has_root { - "/".to_string() - } else if !has_swap { - "swap".to_string() - } else { - String::new() - }; + let suggested = suggest_mountpoint(&state.custom_partitions, self.is_uefi); + self.manual.add_mountpoint = suggested.to_string(); // Mountpoint'e göre otomatik boyut ata - self.manual.add_size_str = match self.manual.add_mountpoint.as_str() { + self.manual.add_size_str = match suggested { "/boot/efi" => "1024".to_string(), // 1GB - "swap" => "1024".to_string(), // Sabit 1GB - "/" => "remaining".to_string(), // Geri kalan alan + "swap" => "1024".to_string(), // Sabit 1GB + // "/" ve "/home" → diskin kalanının tamamı. Veri diski + // olan ikinci bir diskte de bu çoğu zaman istenendir. + "/" | "/home" => "remaining".to_string(), _ => String::new(), }; - self.manual.add_fstype = match self.manual.add_mountpoint.as_str() { + self.manual.add_fstype = match suggested { "swap" => FS_OPTIONS.iter().position(|(_, t)| *t == FsType::Swap).unwrap_or(0), "/boot/efi" => FS_OPTIONS.iter().position(|(_, t)| *t == FsType::Fat32).unwrap_or(0), _ => 0, @@ -2997,14 +3034,143 @@ mod multidisk_tests { validate_manual_on(&with_pv, &[], true, &disks) ); - // Normal bölüm: mountpoint yok → geçersiz + // KULLANICI TANIMI: mountpoint yok → geçersiz. + // + // `orig_device` boş olan bir tanım kullanıcının kendi eklediği + // yeni bölümdür; ne işe yarayacağını söylemeden kaydedilmesine + // izin verilmez. (Fiziksel taranmış bölümlerde mountpoint'in + // boş olması normaldir — aşağıdaki regression test'ine bakın.) + let mut new_def = p("/dev/sda", "/dev/sda2", 5_000, FsType::Ext4, ""); + new_def.orig_device = String::new(); let no_mp = vec![ p("/dev/sda", "/dev/sda1", 20_000, FsType::Ext4, "/"), - p("/dev/sda", "/dev/sda2", 5_000, FsType::Ext4, ""), + new_def, ]; assert!( validate_manual_on(&no_mp, &[], true, &disks).is_some(), - "mountpointsiz normal bölüm reddedilmeli" + "mountpointsiz kullanıcı tanımı reddedilmeli" + ); + } + + // ── REGRESYON: mount EDİLMEMİŞ fiziksel bölüm ────────────── + + /// Canlı ISO'da mount edilmemiş fiziksel bölüm (mountpoint boş) + /// düzeni GEÇERSİZ kılmamalı. + /// + /// REGRESYON: `read_existing_partitions` mountpoint'i lsblk'in + /// MOUNTPOINT sütunundan okur; mount edilmemiş bölümlerde bu sütun + /// boştur. Çift önyüklemeli bir makinede Windows'un boot bölümü + /// neredeyse her zaman bu durumdadır ve kullanıcı ona dokunmamıştır. + /// Yine de düzen "bağlama noktası boş olamaz" ile reddediliyordu — + /// hata, ekrandaki 5 bölümün hangisinden geldiğini söylemeden. + #[test] + fn unmounted_physical_partition_does_not_invalidate_layout() { + let disks = vec![disk("/dev/sda", 250), disk("/dev/sdb", 500)]; + + // sda1 = Windows boot (FAT32, mount edilmemiş) → mountpoint BOŞ + let parts = vec![ + p("/dev/sda", "/dev/sda1", 512, FsType::Fat32, ""), + p("/dev/sdb", "/dev/sdb1", 1_024, FsType::Fat32, "/boot/efi"), + p("/dev/sdb", "/dev/sdb2", 8_192, FsType::Swap, "swap"), + p("/dev/sdb", "/dev/sdb3", 400_000, FsType::Ext4, "/"), + ]; + assert_eq!( + validate_manual_on(&parts, &[], true, &disks), + None, + "mount edilmemiş sda1 düzeni bozmamalı: {:?}", + validate_manual_on(&parts, &[], true, &disks) + ); + } + + /// Birden çok atanmamış fiziksel bölüm "mükerrer bağlama noktası" + /// hatası üretmemeli. + /// + /// REGRESYON: mükerrer kontrolü boş mountpoint'leri de karşılaştırıyordu. + /// İki atanmamış bölüm aynı "" değerini taşıdığı için kullanıcı var + /// olmayan bir çakışma hatası görüyordu — asıl sorun (mountpoint + /// eksikliği) maskeleniyordu. + #[test] + fn several_unassigned_partitions_are_not_duplicate_mountpoints() { + let disks = vec![disk("/dev/sda", 250)]; + let parts = vec![ + p("/dev/sda", "/dev/sda1", 512, FsType::Ntfs, ""), + p("/dev/sda", "/dev/sda2", 8_000, FsType::Ntfs, ""), + p("/dev/sda", "/dev/sda3", 200_000, FsType::Ext4, "/"), + ]; + assert_eq!( + validate_manual_on(&parts, &[], false, &disks), + None, + "iki atanmamış bölüm mükerrer sayılmamalı: {:?}", + validate_manual_on(&parts, &[], false, &disks) + ); + } + + // ── REGRESYON: ikinci diske öneri ────────────────────────── + + /// Kullanıcının gerçek senaryosu: sdb'de kurulum tamam (EFI+swap+kök), + /// sdc tamamen boş. sdc'de "Yeni Bölüm" → öneri `/boot/efi` OLMAZ + /// (sdb1'de zaten var) ve BOŞ DA OLMAZ (kullanıcı ne yapacağını + /// bilemez). `/home` önerilir. + #[test] + fn empty_secondary_disk_suggests_home_not_a_duplicate_efi() { + let parts = vec![ + p("/dev/sdb", "/dev/sdb1", 1_024, FsType::Fat32, "/boot/efi"), + p("/dev/sdb", "/dev/sdb2", 8_192, FsType::Swap, "swap"), + p("/dev/sdb", "/dev/sdb3", 400_000, FsType::Ext4, "/"), + ]; + assert_eq!( + suggest_mountpoint(&parts, true), + "/home", + "boş ikinci diskte /home önerilmeli, mükerrer /boot/efi değil" + ); + } + + /// Öneri TÜM düzeni görmeli — aktif disk değil. + /// + /// REGRESYON: sayım aktif diske bakıyordu; boş bir diske geçince + /// o diskte "EFI yok" sanıp `/boot/efi` öneriyordu. + #[test] + fn suggestion_counts_whole_layout_not_the_active_disk() { + // Aktif disk sdc (hiç tanım yok), ama kurulum sdb'de tamam. + let sdb: Vec = vec![ + p("/dev/sdb", "/dev/sdb1", 1_024, FsType::Fat32, "/boot/efi"), + p("/dev/sdb", "/dev/sdb2", 8_192, FsType::Swap, "swap"), + p("/dev/sdb", "/dev/sdb3", 400_000, FsType::Ext4, "/"), + ]; + let suggested = suggest_mountpoint(&sdb, true); + assert_ne!( + suggested, "/boot/efi", + "başka diskte EFI varken ikinci bir EFI önerilmemeli" + ); + assert!(!suggested.is_empty(), "öneri boş olmamalı, kullanıcı yönlendirilmeli"); + } + + /// Sıralama korunur: kurulum tamamen boşsa kök önerilir. + #[test] + fn suggestion_order_root_before_home() { + assert_eq!(suggest_mountpoint(&[], false), "/"); + assert_eq!(suggest_mountpoint(&[], true), "/boot/efi"); + let mut root = p("/dev/sda", "/dev/sda1", 100_000, FsType::Ext4, "/"); + root.fstype = FsType::Ext4; + assert_eq!(suggest_mountpoint(&[root], false), "swap"); + } + + /// Silinmek işaretli bölümler sayılmamalı — kullanıcı /home'u + /// silip yeniden tanımlamak istiyor, öneri yine `/home` olmalı. + #[test] + fn deleted_definitions_do_not_count_as_taken() { + let mut dead = p("/dev/sdb", "/dev/sdb3", 400_000, FsType::Ext4, "/home"); + dead.delete = true; + let parts = vec![ + p("/dev/sdb", "/dev/sdb1", 1_024, FsType::Fat32, "/boot/efi"), + p("/dev/sdb", "/dev/sdb2", 8_192, FsType::Swap, "swap"), + p("/dev/sdb", "/dev/sdb3", 400_000, FsType::Ext4, "/"), + dead, + ]; + assert_eq!( + suggest_mountpoint(&parts, true), + "/home", + "silinen /home yeniden kullanılabilir olmalı" ); }