diff --git a/locales/en.toml b/locales/en.toml index 5a8b6eb..407994f 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -147,11 +147,16 @@ display_manager_de_not_found = "(not detected)" display_manager_note = "Note: The display manager will be enabled by default on the installed system." mp_size_remaining = "Remaining Space" mp_size_available = "Available" +mp_target_disk = "Target disk" +mp_target_disk_hint = "The disk this partition will be created on. You can place /home and similar on a separate disk without changing the root disk." 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_multiple_root = "More than one partition is defined as root (/): {disks}. There must be exactly one root partition" +mp_partition_no_disk = "A partition definition has no disk selected. Choose the target disk for every partition." +mp_disk_not_found = "{disk} is no longer attached. Remove the partition definitions on it, or reconnect the disk." mp_bar_now = "Current Layout" mp_bar_after = "After Installation" diff --git a/locales/tr.toml b/locales/tr.toml index 785695d..152c682 100644 --- a/locales/tr.toml +++ b/locales/tr.toml @@ -119,11 +119,16 @@ summary_partition_plan = "Bölümleme" summary_custom_partitions = "Manuel Bölümler" mp_size_remaining = "Kalan Alan" mp_size_available = "Kullanılabilir" +mp_target_disk = "Hedef disk" +mp_target_disk_hint = "Bu bölümün yerleşeceği disk. Kök diski değiştirmeden /home gibi bölümleri ayrı bir diske koyabilirsiniz." 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_multiple_root = "Birden fazla bölüm kök (/) olarak tanımlanmış: {disks}. Kök tek bir bölüm olmalı" +mp_partition_no_disk = "Bir bölüm tanımı için disk seçilmemiş. Her bölümün yerleşeceği diski seçin." +mp_disk_not_found = "{disk} artık bağlı değil. Bu diske yapılan bölüm tanımını kaldırın ya da diski yeniden bağlayın." mp_bar_now = "Mevcut Durum" mp_bar_after = "Kurulum Sonrası" diff --git a/src/jobs/bootloader.rs b/src/jobs/bootloader.rs index abb2084..8995d87 100644 --- a/src/jobs/bootloader.rs +++ b/src/jobs/bootloader.rs @@ -47,6 +47,36 @@ async fn luks_backing_device(name: &str) -> Result { }) } +/// Bir bağlama noktasının arkasındaki GERÇEK aygıtı döndürür. +/// +/// Kurulum kökü `mount` noktasına bağlandıktan sonra çağrılır; `findmnt` +/// doğrudan cevabı verir. Böylece kök bölümünün numarası veya diski +/// tahmin edilmez — manuel modda numaralar `assign_partition_numbers`'tan +/// gelir ve kök farklı bir diskte olabilir. +async fn mounted_root_device(mount: &str) -> Result { + let out = tokio::process::Command::new("findmnt") + .args(["-n", "-o", "SOURCE", "--target", mount]) + .output() + .await + .map_err(|e| format!("findmnt çalıştırılamadı: {}", e))?; + + let dev = String::from_utf8_lossy(&out.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + + // findmnt bazen /dev/... yerine /dev/mapper/... ya da kökü verir. + if dev.is_empty() || dev == "none" { + return Err(format!( + "{} bağlama noktasının arkasındaki aygıt çözümlenemedi; \ + kök bölümü bağlanmamış olabilir", mount + )); + } + Ok(dev) +} + /// initramfs yeniler (GRUB öncesi zorunlu). /// PisiLinux `dracut` kullanıyorsa `dracut -f --regenerate-all`, /// mkinitcpio kullanıyorsa `mkinitcpio -P`. @@ -498,10 +528,18 @@ async fn generate_minimal_grub_cfg( tokio::fs::create_dir_all(&grub_dir).await .map_err(|e| format!("{} oluşturulamadı: {}", grub_dir, e))?; - let (root_num, _swap_num) = if is_uefi { (3u32, 2u32) } else { (2u32, 1u32) }; - let root_part = part_path(disk, root_num); - let root_uuid = blkid_uuid(&root_part).await - .map_err(|e| format!("Kök UUID okunamadı: {}", e))?; + // Kök aygıtı TAHMİN EDİLMEZ. + // + // Daha önce `is_uefi` ile (3 | 2) deniyor ve `disk` (seçili disk) + // üzerinden okunuyordu. Bu manuel modda iki yönden yanlıştır: + // 1) numaralar assign_partition_numbers'tan gelir, (3|2) değil + // 2) kök farklı bir diskteyse (çoklu disk kurulumu) `disk` üstünde + // hiç kök yoktur + // Kurulum kökü `mount` noktasına BAĞLANDIĞI için gerçek aygıt + // findmnt'den sorulur — hem otomatik hem manuel modda doğru. + let root_device = mounted_root_device(mount).await?; + let root_uuid = blkid_uuid(&root_device).await + .map_err(|e| format!("Kök UUID okunamadı ({}): {}", root_device, e))?; let mut kernel_file: Option = None; let mut initrd_files: Vec = Vec::new(); @@ -552,8 +590,14 @@ async fn generate_minimal_grub_cfg( cfg.push_str(&format!(" search --no-floppy --fs-uuid --set=root {}\n", root_uuid)); cfg.push_str(&format!(" linux /boot/{} root=UUID={} rw quiet", kernel, root_uuid)); } else { + // BIOS'ta `set root=(hdX,msdosY)` gerekir. hd0 ve msdos2 + // SABİT YAZILIYORDU; kök başka bir diskte/bölümdeyse yanlış + // bölüm boot edilirdi. numara artık gerçek kök aygıtından gelir + // (BIOS sıralaması için bkz. cihaz.map notu aşağıda). + let msdos = crate::installer::part_number(&root_device).unwrap_or(2); cfg.push_str("menuentry \"PisiLinux\" {\n"); - cfg.push_str(" insmod ext2\n set root=(hd0,msdos2)\n"); + cfg.push_str(" insmod ext2\n"); + cfg.push_str(&format!(" set root=(hd0,msdos{})\n", msdos)); cfg.push_str(&format!(" linux /boot/{} root=UUID={} rw quiet", kernel, root_uuid)); } diff --git a/src/jobs/partition.rs b/src/jobs/partition.rs index 6743382..230cafa 100644 --- a/src/jobs/partition.rs +++ b/src/jobs/partition.rs @@ -1,13 +1,287 @@ +use std::collections::HashMap; use std::sync::atomic::Ordering; use tokio::process::Command; use crate::installer::{CustomPartition, FsType, PartitionPlan, PartitionTableType}; use super::{blkid_uuid, part_path, run_cmd, DEMO_MODE, Job, UiSender}; +// ───────────────────────────────────────────── +// YARDIMCI: DISK DÜZENİ HESAPLAMA (saf fonksiyonlar) +// ───────────────────────────────────────────── + +/// Bir diskte silinmeyen fiziksel bölümün geometrisi. +#[derive(Debug, Clone, PartialEq)] +pub struct PhysicalExtent { + /// Mevcut kernel numarası (ör. 3 → /dev/sda3) + pub num: u32, + pub start_mb: u64, + pub end_mb: u64, +} + +/// Bir diskte oluşturulacak yeni bölümün planlanmış konumu. +#[derive(Debug, Clone, PartialEq)] +pub struct PlannedCreate { + /// `custom_partitions` içindeki sırası (eşleştirme için) + pub part_index: usize, + pub start_mb: u64, + pub end_mb: u64, + /// parted `mkpart` sonrası kernel'in vereceği GERÇEK bölüm numarası. + pub num: u32, + pub device: String, + /// `size_mb == 0` ("kalan alan") mı + pub symbolic: bool, +} + +/// Bir diskin kurulum SONRASI bölüm düzenini hesaplar. +/// +/// Neden ayrı bir saf fonksiyon: bölüm numaralandırması iki ayrı yerde +/// (UI'daki `assign_partition_numbers` ve job'daki `mkpart`/`mkfs`) +/// bağımsız hesaplanıyordu ve bu ikisi ayrışabiliyordu. Burada tek +/// kaynakta, diskin TAMAMINI (fiziksel + yeni) görerek hesaplanır; +/// çağıranlar sonucu kullanır, yeniden hesaplamaz. +/// +/// ## parted davranışı ve numaralandırma +/// +/// `parted rm N` sonrası kernel, bölümleri BAŞLIK OFFSET'İNE GÖRE +/// sıralayıp 1'den numaralandırır. Yani `sda1, sda2, sda3` varken +/// `rm 2` sonrası `sda2` (eskisi 3) olur. `mkpart` ile oluşturulan +/// bölüm de başlangıç offset'ine göre listenin doğru yerine girer. +/// +/// Sonuç: bir bölümün sonraki numarası = başlangıç offset'ine göre +/// sıralanmış listedeki 1 tabanlı indeksi. Bu fonksiyon tam olarak +/// bunu hesaplar — `mkpart`'tan ÖNCE, böylece `mkfs`/`set esp` +/// doğru aygıta uygulanır. +/// +/// ## Örnek (uyumsuzluğun gerçekleştiği senaryo) +/// +/// ```text +/// sda1 0–100 (korunuyor) +/// sda2 100–200 (SİLİNECEK → 100 MB'lık boşluk) +/// sda3 200–30200 (korunuyor) +/// disk sonu: 10000 MB boş +/// yeni: 5000 MB +/// +/// UI : sda2 silinince slot 2 boşalır → yeni bölüm sda2 olur. +/// Disk: boşluk 100–200 yalnızca 100 MB, 5000 sığmaz → mkpart +/// diskin sonuna (30200→35200) yazar → kernel sırası: +/// sda1, sda2(eskisi 3), sda3(YENİ). +/// Sonuç: UI "yeni = sda2" der, diskte yeni = sda3 ve eski sda3 = sda2. +/// mkfs yanlış aygıta uygulanır → veri kaybı. +/// ``` +/// +/// `physical`'den `num`'u silinen bölümler ÇIKARILMAMALIDIR; onların +/// geometrisi hesaba katılmaz, numaraları da yeniden dağıtılmaz. +pub fn plan_disk_layout( + disk: &str, + total_mb: u64, + physical: &[PhysicalExtent], + destroyed: &[u32], + creates: &[(usize, u64)], +) -> Result<(Vec, Vec<(u32, String)>), String> { + // 1. Silinmeyen fiziksel bölümler + let mut survivors: Vec<(u64, u64, Option)> = physical + .iter() + .filter(|p| !destroyed.contains(&p.num)) + .map(|p| (p.start_mb, p.end_mb, Some(p.num))) + .collect(); + + // 2. Yeni bölümler için boş bölgeler (başlangıç offset'ine göre sıralı) + let mut regions = free_regions_from(&survivors, total_mb); + + let mut planned: Vec = Vec::with_capacity(creates.len()); + + for &(part_index, size_mb) in creates { + let symbolic = size_mb == 0; + let (start_mb, end_mb) = if symbolic { + // "Kalan alan": EN BÜYÜK boş bölgenin tamamı. + let idx = regions + .iter() + .enumerate() + .max_by_key(|(_, (s, e))| e.saturating_sub(*s)) + .map(|(i, _)| i); + match idx { + Some(i) => regions.remove(i), + None => { + return Err(format!( + "{} üzerinde kalan alan için boş bölge yok", disk + )) + } + } + } else { + // Sabit boyut: sığan İLK boş bölge (first-fit). + let mut found = None; + for (i, (s, e)) in regions.iter().enumerate() { + if e.saturating_sub(*s) >= size_mb { + found = Some((i, *s)); + break; + } + } + match found { + Some((i, s)) => { + let new_start = s + size_mb; + if regions[i].1 > new_start { + regions[i] = (new_start, regions[i].1); + } else { + regions.remove(i); + } + (s, s + size_mb) + } + None => { + return Err(format!( + "{} üzerinde {} MB için yeterli boş alan yok", disk, size_mb + )) + } + } + }; + + planned.push(PlannedCreate { + part_index, + start_mb, + end_mb, + num: 0, // aşağıda numaralandırılacak + device: String::new(), + symbolic, + }); + survivors.push((start_mb, end_mb, None)); + } + + // 3. Başlangıç offset'ine göre sırala → kernel numaralarını dağıt. + // `parted` bölüm tablosunu bu sırayla gösterir; kernel de aynı + // sırayı kullanır. + survivors.sort_by_key(|(s, _, _)| *s); + + let mut planned_by_index: HashMap = HashMap::new(); + let mut remapped: Vec<(u32, String)> = Vec::new(); + + for (idx, (start, _, orig)) in survivors.iter().enumerate() { + let num = (idx + 1) as u32; + let device = part_path(disk, num); + match orig { + // Fiziksel bölüm: yeni numarasını öğren + Some(old) => { + if *old != num { + remapped.push((*old, device.clone())); + } + } + // Yeni bölüm: hangi tanım olduğunu eşle + None => { + // Bu aralık hangi planlanmış bölüme ait? + if let Some(p) = planned + .iter_mut() + .find(|p| p.start_mb == *start && p.num == 0) + { + p.num = num; + p.device = device.clone(); + planned_by_index.insert(p.part_index, num); + } + } + } + } + + // Eşleşmeyen planlanmış bölüm kalmamalı + debug_assert_eq!( + planned_by_index.len(), + planned.len(), + "{}: planlanan bölüm sayısı ile eşleşen sayı uyuşmuyor", disk + ); + + Ok((planned, remapped)) +} + +/// Başlangıç offset'ine göre sıralı, dolu aralıklardan boş bölgeleri üretir. +/// +/// `total_mb` diskin GERÇEK boyutudur (sysfs'ten okunur). Son bölümün +/// bitişiyle tahmin yürütülmez: bölümler diskin sonuna kadar +/// dolmamış olabilir ve o boşluk "kalan alan" için kullanılabilir. +fn free_regions_from(occupied: &[(u64, u64, Option)], total_mb: u64) -> Vec<(u64, u64)> { + const HEADROOM_MB: u64 = 1; // İlk 1 MiB: boot/GRUB önyükleme kaydı + let usable_total = total_mb.saturating_sub(1); // disk sonu güvenlik payı + + let mut sorted: Vec<(u64, u64)> = occupied.iter().map(|(s, e, _)| (*s, *e)).collect(); + sorted.sort(); + + let mut regions: Vec<(u64, u64)> = Vec::new(); + let mut cursor = HEADROOM_MB; + for (s, e) in &sorted { + if *s > cursor { + regions.push((cursor, *s)); + } + cursor = cursor.max(*e); + } + if cursor < usable_total { + regions.push((cursor, usable_total)); + } + regions.retain(|(s, e)| e > s); + regions +} + // ───────────────────────────────────────────── // YARDIMCI: DISK / BÖLÜMLEME KOMUTLARI // ───────────────────────────────────────────── +/// Diskin toplam boyutunu MB cinsinden okur (sysfs). +fn disk_total_mb(disk: &str) -> Result { + const SECTOR: u64 = 512; + const MB: u64 = 1024 * 1024; + let base = disk.trim_start_matches("/dev/"); + let sectors: u64 = std::fs::read_to_string(format!("/sys/block/{}/size", base)) + .map_err(|e| format!("{} boyutu okunamadı: {}", disk, e))? + .trim() + .parse() + .unwrap_or(0); + if sectors == 0 { + return Err(format!("{} disk boyutu okunamadı", disk)); + } + Ok(sectors * SECTOR / MB) +} + +/// Diskteki MEVCUT fiziksel bölümlerin geometrisini sysfs'ten okur. +/// +/// `plan_disk_layout`'a girdi olarak verilir. Silinmek üzere işaretlenen +/// bölümler de burada LİSTELENİR; `plan_disk_layout` `destroyed` listesine +/// bakarak kendisi eler. +fn read_physical_extents(disk: &str) -> Result, String> { + const SECTOR: u64 = 512; + const MB: u64 = 1024 * 1024; + let base = disk.trim_start_matches("/dev/"); + + let mut out: Vec = Vec::new(); + let entries = match std::fs::read_dir(format!("/sys/block/{}", base)) { + Ok(e) => e, + // Disk okunamadıysa planlama yapılamaz; hata ver. + Err(e) => return Err(format!("{} okunamadı: {}", disk, e)), + }; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.starts_with(base) || name == base { + continue; + } + let Some(num) = crate::installer::part_number(&format!("/dev/{}", name)) else { + continue; + }; + let start_sectors: u64 = std::fs::read_to_string(entry.path().join("start")) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + let len_sectors: u64 = std::fs::read_to_string(entry.path().join("size")) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + if len_sectors == 0 { + continue; + } + let start_mb = (start_sectors * SECTOR + MB - 1) / MB; + let end_mb = ((start_sectors + len_sectors) * SECTOR) / MB; + if end_mb > start_mb { + out.push(PhysicalExtent { num, start_mb, end_mb }); + } + } + + out.sort_by_key(|p| p.start_mb); + Ok(out) +} + /// `parted` komutlarını çalıştırmak için özel sarmalayıcı. /// /// `parted -s` canlı sistemde kullanımda olan bir disk üzerinde bölüm @@ -132,12 +406,14 @@ async fn prepare_disk(disk: &str, ui: &UiSender) { // 1. Diskteki aktif swap'ları kapat ui.log(format!("Swap ayrılıyor: {}…", disk)); - let lsblk = tokio::process::Command::new("lsblk") + // Aynı lsblk çıktısı hem swap hem umount için kullanılır. + let lsblk_out = tokio::process::Command::new("lsblk") .args(["-o", "NAME,TYPE", "-l", "--noheadings", disk]) - .output().await; - if let Ok(out) = lsblk { - let text = String::from_utf8_lossy(&out.stdout); - for line in text.lines() { + .output().await + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default(); + { + for line in lsblk_out.lines() { if line.contains("part") { if let Some(name) = line.split_whitespace().next() { let dev = format!("/dev/{}", name); @@ -149,12 +425,21 @@ async fn prepare_disk(disk: &str, ui: &UiSender) { } // 2. Disk bölümlerini umount et (hata görmezden gelinir) + // + // Daha önce `1..=32` numaralandırılıyor ve İLK EKSİK bölümde `break` + // ediliyordu. Seyrek bir düzende (sda1 + sda3, sda2 yok) döngü n=2'de + // duruyor ve sda3 HİÇ çözülmüyordu → bağlı kalan bölüm yüzünden + // `parted mkpart` "device busy" ile başarısız oluyordu. + // + // Bunun yerine gerçek bölümler lsblk'den alınır; numaralar seyrek + // olabilir, üst sınır `lsblk` çıktısıyla belirlenir. ui.log(format!("Bağlantılar ayrılıyor: {}…", disk)); - for n in 1..=32u32 { - let part = crate::installer::part_path(disk, n); - if !std::path::Path::new(&part).exists() { break; } + for line in lsblk_out.lines() { + if !line.contains("part") { continue; } + let Some(name) = line.split_whitespace().next() else { continue }; + let dev = format!("/dev/{}", name); let _ = tokio::process::Command::new("umount") - .args(["-lf", &part]).status().await; + .args(["-lf", &dev]).status().await; } // 3. İlk senkronizasyon @@ -466,64 +751,6 @@ async fn read_partition_uuids( // MANUEL BÖLÜMLEME İÇİN DESTEKLEYİCİ JOB'LAR // ───────────────────────────────────────────── -/// Diskteki boş bölgeleri MB cinsinden hesaplar (sysfs tabanlı). -/// -/// `exclude_nums`: silinecek işaretli bölümler — boşluk olarak -/// davranılır (kurulum sırasında önce silindikleri için). -/// Dönen değer: (başlangıç_mb, bitiş_mb) aralıkları; disk sonunda -/// GPT yedek başlığı için 1MB pay bırakılır. -fn disk_free_regions_mb(disk: &str, exclude_nums: &[u32]) -> Result, String> { - let base = disk.trim_start_matches("/dev/"); - const SECTOR: u64 = 512; - const MB: u64 = 1024 * 1024; - - let disk_sectors: u64 = std::fs::read_to_string(format!("/sys/block/{}/size", base)) - .map_err(|e| format!("{} boyutu okunamadı: {}", disk, e))? - .trim().parse().unwrap_or(0); - if disk_sectors == 0 { - return Err(format!("{} disk boyutu okunamadı", disk)); - } - let total_mb = disk_sectors * SECTOR / MB; - let usable_total = total_mb.saturating_sub(1); // disk sonu güvenlik payı - - // Dolu aralıklar - let mut occupied: Vec<(u64, u64)> = Vec::new(); - if let Ok(entries) = std::fs::read_dir(format!("/sys/block/{}", base)) { - for entry in entries.flatten() { - let name = entry.file_name().to_string_lossy().to_string(); - if !name.starts_with(base) || name == base { continue; } - let num = match crate::installer::part_number(&format!("/dev/{}", name)) { - Some(n) => n, - None => continue, - }; - if exclude_nums.contains(&num) { continue; } - let start_sectors: u64 = std::fs::read_to_string(entry.path().join("start")) - .ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0); - let len_sectors: u64 = std::fs::read_to_string(entry.path().join("size")) - .ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0); - if len_sectors == 0 { continue; } - let start_mb = (start_sectors * SECTOR + MB - 1) / MB; - let end_mb = ((start_sectors + len_sectors) * SECTOR) / MB; - if end_mb > start_mb { - occupied.push((start_mb, end_mb.min(usable_total))); - } - } - } - occupied.sort(); - - // Boşlukları çıkar (ilk 1MB boot alanı hariç) - const HEADROOM_MB: u64 = 1; - let mut regions: Vec<(u64, u64)> = Vec::new(); - let mut cursor = HEADROOM_MB.min(usable_total); - for (s, e) in &occupied { - if *s > cursor { regions.push((cursor, *s)); } - cursor = cursor.max(*e); - } - if cursor < usable_total { regions.push((cursor, usable_total)); } - regions.retain(|(s, e)| e > s); - Ok(regions) -} - /// Manuel bölümleme ayarlarını granular (eski Yali Operation modeli) /// yaklaşımla uygulayan Job. /// @@ -547,6 +774,14 @@ impl Job for CustomPartitionJob { // MBR'de "primary" zorunludur, GPT'de ise bu alan isim (label) alanıdır. let part_type_or_name = if is_uefi { "" } else { "primary" }; + // UI'ın hesapladığı aygıt yolu → kurulum sonrası GERÇEK aygıt yolu. + // + // `parted rm` silinen bölümden sonrakileri bir kaydırır ve `mkpart` + // yeni bölümü başlangıç offset'ine göre yerleştirir; bu yüzden + // UI'ın "en küçük boş slot" tahmini diskteki gerçek numaradan + // farklı olabilir. FAZ B bu farkı burada kaydeder, FAZ C okur. + let mut device_map: HashMap = HashMap::new(); + // 0. İşlem yapılan diskleri tanım sırasına göre topla let mut disks: Vec = Vec::new(); for part in &self.custom_partitions { @@ -597,9 +832,53 @@ impl Job for CustomPartitionJob { } // ── FAZ B: YENİ BÖLÜMLER (boş alana yerleştir) ────────────── + // + // Yerleşim VE numaralandırma `plan_disk_layout` ile ÖNCEDEN + // hesaplanır. Daha önce burada ayrı bir first-fit vardı ve + // bölüm numarası `part.device`'den (UI'ın tahmini) okunuyordu. + // Bu ikisi ayrışabiliyordu: silinen bir orta bölümden kalan + // boşluk istenen boyuta SĞMADIĞINDA mkpart diskin sonuna + // yazıyor, kernel yeni bölüme 3 numarasını veriyor, ama UI + // 2 demişti. FAZ C mkfs'i yanlış aygıta uyguluyordu — + // kullanıcının KORUMAK İSTEDİĞİ bölüm biçimlendiriliyordu. + // + // Artık `plan_disk_layout` tüm diski görüp başlangıç + // offset'ine göre numaralandırıyor; hem yeni bölümlerin + // hem de silmeden sonra KAYAN fiziksel bölümlerin gerçek + // yolları buradan dönüyor. if !creates.is_empty() { - let mut regions = disk_free_regions_mb(disk, &destroys)?; - for part in &creates { + let total_mb = disk_total_mb(disk)?; + let physical = read_physical_extents(disk)?; + // Kimlik olarak `creates` içindeki sıra kullanılır; LvmLv + // tanımlar planlanmaz (FAZ B parted ile oluşturmaz). + let create_pos: Vec = creates + .iter() + .enumerate() + .filter(|(_, p)| p.fstype != FsType::LvmLv) + .map(|(i, _)| i) + .collect(); + let create_specs: Vec<(usize, u64)> = create_pos + .iter() + .map(|&i| (i, creates[i].size_mb)) + .collect(); + + let (planned, remapped) = + plan_disk_layout(disk, total_mb, &physical, &destroys, &create_specs)?; + + // Silmeden sonra kaymış fiziksel bölümler: FAZ C bunları + // UI'ın numarasıyla değil, GERÇEK yollarıyla işleyecek. + for (old_num, new_dev) in &remapped { + ui.log(format!( + "{}: {} → {} (silinen bölüm nedeniyle numara kaydı)", + disk, part_path(disk, *old_num), new_dev + )); + device_map.insert(part_path(disk, *old_num), new_dev.clone()); + } + + for plan in &planned { + let part = creates[plan.part_index]; + // Yeni bölüm: UI'ın tahmin ettiği yol → gerçek yol. + device_map.insert(part.device.clone(), plan.device.clone()); let fs_label = match part.fstype { FsType::Fat32 => "fat32", FsType::Swap => "linux-swap", @@ -613,58 +892,17 @@ impl Job for CustomPartitionJob { FsType::LvmLv => continue, }; - // Yerleşim: boyut belirtilmişse sığan İLK boş bölge, - // "kalan alan" ise en büyük bölgenin tamamı. - let need = part.size_mb; - let placement = if need == 0 { - let idx = regions.iter().enumerate() - .max_by_key(|(_, (s, e))| e.saturating_sub(*s)) - .map(|(i, _)| i); - match idx { - Some(i) => { - let r = regions.remove(i); - Some(r) - } - None => None, - } - } else { - let mut found = None; - for (i, (s, e)) in regions.iter().enumerate() { - if e.saturating_sub(*s) >= need { - found = Some((i, (*s, s + need))); - break; - } - } - match found { - Some((i, seg)) => { - let (_, e) = regions[i]; - let start = seg.0; - let new_start = start + need; - if e > new_start { - regions[i] = (new_start, e); - } else { - regions.remove(i); - } - Some(seg) - } - None => None, - } - }; - - let (start_mb, end_mb) = placement.ok_or_else(|| format!( - "{} üzerinde {} MB için yeterli boş alan yok", disk, need))?; - // DİKKAT: parted "MB" sonekini onalık (10^6) okur; // hesaplarımız binary (2^20) olduğu için "MiB" zorunlu. // Tamsayı MiB = 2048 sektörün katı → parted hizalama // hatası vermez. - let start_str = format!("{}MiB", start_mb); - let end_str = format!("{}MiB", end_mb); - let slot = crate::installer::part_number(&part.device).unwrap_or(0); + let start_str = format!("{}MiB", plan.start_mb); + let end_str = format!("{}MiB", plan.end_mb); + let slot_str = plan.num.to_string(); ui.log(format!( - "{} üzerinde yeni bölüm oluşturuluyor: {} - {} ({})", - disk, start_str, end_str, fs_label + "{} üzerinde yeni bölüm oluşturuluyor: {} - {} ({}, slot {})", + disk, start_str, end_str, fs_label, plan.num )); run_parted(&[ "-s", disk, "mkpart", part_type_or_name, fs_label, @@ -672,7 +910,7 @@ impl Job for CustomPartitionJob { ]).await?; if part.mountpoint == "/boot/efi" || part.fstype == FsType::Fat32 { - run_parted(&["-s", disk, "set", &slot.to_string(), "esp", "on"]).await?; + run_parted(&["-s", disk, "set", &slot_str, "esp", "on"]).await?; } } sync_partition_table(disk).await; @@ -685,7 +923,18 @@ impl Job for CustomPartitionJob { // Silinmek işaretli bölümlere hiç dokunulmaz. for part in &self.custom_partitions { if part.delete { continue; } - let path = &part.device; + // FAZ B'nin hesapladığı GERÇEK aygıt yolu. Kayıt yoksa + // (yeni bölüm yok, silme yok) UI'ın yolu doğrudur. + let path: &str = device_map + .get(&part.device) + .map(|s| s.as_str()) + .unwrap_or(part.device.as_str()); + if path != part.device { + ui.log(format!( + "{} → {} (bölüm numarası kaydı, gerçek aygıt kullanılıyor)", + part.device, path + )); + } // LUKS şifreleme (kök veya isteğe bağlı bölümler) // Şifre, shell injection'ı önlemek için doğrudan stdin pipe üzerinden @@ -708,7 +957,7 @@ impl Job for CustomPartitionJob { format!("/dev/mapper/{}", luks_name) } else { - path.clone() + path.to_string() }; if part.skip_format { @@ -851,6 +1100,14 @@ impl Job for MountCustomPartitionsJob { let mut mount_items = Vec::new(); for part in &self.custom_partitions { + // LVM PV mount EDİLMEZ ve fstab'a girmez; üzerinde mantıksal + // hacimler yaşar (pvcreate → vgcreate → lvcreate). Diyalog her + // tanım için mountpoint zorunlu kıldığı için bir PV'nin + // mountpoint'i vardı ve buraya düşerek `mount` deneniyordu → + // "wrong fs type" ile kurulumun tamamı düşüyordu. + if part.fstype == FsType::Lvm || part.fstype == FsType::LvmLv { + continue; + } if part.mountpoint != "/" && part.fstype != FsType::Swap && !part.mountpoint.is_empty() && !part.delete { mount_items.push(MountItem { device: self.device_path(part), @@ -912,6 +1169,14 @@ impl Job for FstabFromCustomPartitionsJob { for part in &self.custom_partitions { if part.mountpoint.is_empty() || part.delete { continue; } + // LVM PV fstab'a girmez. PV bir "dosya sistemi bağlama + // noktası" değildir; üzerindeki mantıksal hacimler LVM + // tarafından yönetilir. Daha önce buraya düşüp ext4 satırı + // olarak yazılıyordu, sistem açılışta "bad fs" uyarısı veriyordu. + if part.fstype == FsType::Lvm || part.fstype == FsType::LvmLv { + ui.log(format!("{} (LVM PV) fstab'a yazılmıyor", part.device)); + continue; + } let is_encrypted = part.encrypt && !part.luks_password.is_empty(); @@ -1008,3 +1273,191 @@ impl Job for FstabFromCustomPartitionsJob { Ok(()) } } + +#[cfg(test)] +mod layout_tests { + use super::*; + + fn phys(num: u32, start: u64, end: u64) -> PhysicalExtent { + PhysicalExtent { num, start_mb: start, end_mb: end } + } + + /// REGRESYON — asıl bulgu. + /// + /// `assign_partition_numbers` UI'da yeni bölüme "en küçük boş slot"u + /// (= 2) veriyor. Ancak `parted rm 2` sonrası serbest kalan boşluk + /// yalnızca 100 MB; 5000 MB'lık istenen bölüm oraya SIĞMAZ ve + /// first-fit diskin SONUNA kayar. parted bölümleri başlangıç + /// offset'ine göre sıraladığı için kernel'de yeni bölüm 3 olur. + /// + /// UI "sda2 = yeni" derken diskte "sda3 = yeni" olurdu; FAZ C + /// eski sda3'ü (artık sda2) biçimlendirirdi → VERİ KAYBI. + #[test] + fn delete_plus_create_renumbers_physical_partitions() { + let physical = vec![ + phys(1, 1, 100), // korunuyor + phys(2, 100, 200), // SİLİNECEK + phys(3, 200, 30_200), // korunuyor + ]; + // 5000 MB'lik yeni bölüm + let creates = vec![(0usize, 5_000u64)]; + + let (planned, remapped) = + plan_disk_layout("/dev/sda", 40_000, &physical, &[2], &creates).expect("planlanmalı"); + + // Yeni bölüm 100–200 boşluğuna SIĞMAZ → diskin sonuna (30200+) gider + assert_eq!( + planned[0].start_mb, 30_200, + "boşluk 100 MB olduğu için yeni bölüm diskin sonuna yerleşmeli" + ); + + // Başlangıç offset'ine göre sıra: sda1(1), sda2(200), sda3(30200) + assert_eq!(planned[0].num, 3, "yeni bölümün gerçek numarası 3 olmalı"); + assert_eq!(planned[0].device, "/dev/sda3"); + + // Eski sda3 artık sda2 — FAZ C bunu bilmeli + assert_eq!( + remapped, vec![(3, "/dev/sda2".to_string())], + "silinen orta bölüm, sonrakileri bir kaydırmalı" + ); + } + + /// Sadece silme, yeni bölüm yok: hayatta kalanlar kayar. + #[test] + fn deleting_middle_shifts_later_partitions_down() { + let physical = vec![ + phys(1, 1, 100), + phys(2, 100, 200), + phys(3, 200, 300), + ]; + let (planned, remapped) = + plan_disk_layout("/dev/sda", 40_000, &physical, &[2], &[]).expect("planlanmalı"); + + assert!(planned.is_empty(), "yeni bölüm yok"); + // sda1→sda1, sda3→sda2 + assert_eq!(remapped, vec![(3, "/dev/sda2".to_string())]); + } + + /// Silinen bölüm sonuncusa hiçbir şey kaymaz. + #[test] + fn deleting_last_partition_shifts_nothing() { + let physical = vec![phys(1, 1, 100), phys(2, 100, 200)]; + let (_, remapped) = + plan_disk_layout("/dev/sda", 40_000, &physical, &[2], &[]).expect("planlanmalı"); + assert!(remapped.is_empty(), "son bölüm silinince kayma olmaz"); + } + + /// Basit durum: silme yok, yeni bölüm diskin sonuna. + #[test] + fn simple_create_after_existing_partitions() { + let physical = vec![phys(1, 1, 100), phys(2, 100, 30_100)]; + // Disk 50_000 MB: son bölüm 30_100'de bitiyor, 19_899 MB boş var. + let (planned, remapped) = + plan_disk_layout("/dev/sda", 50_000, &physical, &[], &[(0, 10_000)]) + .expect("planlanmalı"); + + assert_eq!(planned[0].start_mb, 30_100); + assert_eq!(planned[0].num, 3); + assert_eq!(planned[0].device, "/dev/sda3"); + assert!(remapped.is_empty(), "fiziksel bölümler kaymamalı"); + } + + /// "Kalan alan" (sembolik) en büyük bölgeye gider. + #[test] + fn symbolic_takes_the_largest_region() { + // İki boş bölge: 200–300 (küçük) ve 1000–20000 (büyük) + let physical = vec![phys(1, 1, 200), phys(2, 300, 1000)]; + let (planned, _) = plan_disk_layout("/dev/sda", 20_000, &physical, &[], &[(0, 0)]) + .expect("planlanmalı"); + assert_eq!(planned[0].start_mb, 1000, "en büyük bölge seçilmeli"); + assert!(planned[0].symbolic); + } + + /// Çoklu yeni bölüm: sabit boyutlular ÖNCE, sembolikler SONRA + /// işlenir (job sırasıyla aynı) ve numaralar art arda da olabilir. + #[test] + fn multiple_creates_get_increasing_slots() { + let physical = vec![phys(1, 1, 100)]; + let creates = vec![(0usize, 2_000u64), (1usize, 3_000u64), (2usize, 0u64)]; + let (planned, _) = plan_disk_layout("/dev/sda", 40_000, &physical, &[], &creates) + .expect("planlanmalı"); + + assert_eq!(planned.len(), 3); + assert_eq!(planned[0].num, 2); + assert_eq!(planned[1].num, 3); + assert_eq!(planned[2].num, 4); + // Art arda yerleşmiş olmalı + assert_eq!(planned[0].end_mb, planned[1].start_mb); + assert_eq!(planned[1].end_mb, planned[2].start_mb); + } + + /// Sığmayan bölüm hata verir — sessizce atlanmaz. + #[test] + fn oversized_create_is_rejected() { + let physical = vec![phys(1, 1, 100)]; + let err = plan_disk_layout("/dev/sda", 40_000, &physical, &[], &[(0, 999_999)]) + .expect_err("sığmayan bölüm hata vermeli"); + assert!(err.contains("yeterli boş alan yok"), "{}", err); + } + + /// Sembolik bölüm için boş alan yoksa hata. + #[test] + fn symbolic_without_free_space_is_rejected() { + let physical = vec![phys(1, 1, 100)]; + // Disk tamamen dolu: 100 MB + güvenlik payı, fazlası yok. + let err = plan_disk_layout("/dev/sda", 100, &physical, &[], &[(0, 0)]) + .expect_err("boş alan yoksa hata vermeli"); + assert!(err.contains("kalan alan"), "{}", err); + } + + /// ÇOKLU DİSK: iki diskin düzeni birbirinden bağımsız hesaplanır. + /// sda1/sdb1 çakışmamalı. + #[test] + fn layouts_are_independent_per_disk() { + let sda = vec![phys(1, 1, 100), phys(2, 100, 30_100)]; + let sdb = vec![phys(1, 1, 100)]; + + let (p_sda, r_sda) = + plan_disk_layout("/dev/sda", 40_000, &sda, &[], &[(0, 1_000)]).expect("sda"); + let (p_sdb, r_sdb) = + plan_disk_layout("/dev/sdb", 40_000, &sdb, &[], &[(0, 2_000)]).expect("sdb"); + + // sda'da 2 fiziksel bölüm var → yeni 3 olur; sdb'de 1 var → yeni 2. + // Kritik olan ikisinin de AYRI kalması: sda1 ile sdb1 çakışmamalı. + assert_eq!(p_sda[0].device, "/dev/sda3"); + assert_eq!(p_sdb[0].device, "/dev/sdb2"); + assert!(r_sda.is_empty() && r_sdb.is_empty()); + } + + /// EN KRİTİK ÇOKLU DİSK SENARYOSU: kök sda'da, /home sdb'de. + /// sda'da silme + yeni bölüm varken sdb'nin numaraları KAYMAMALI. + #[test] + fn deleting_on_one_disk_does_not_renumber_another() { + // sda: sda1 korunuyor, sda2 siliniyor, yeni 5000 MB + let sda = vec![phys(1, 1, 100), phys(2, 100, 200)]; + let (p_sda, r_sda) = + plan_disk_layout("/dev/sda", 40_000, &sda, &[2], &[(0, 5_000)]).expect("sda"); + + // sdb: bağımsız — kendi fiziksel bölümü + let sdb = vec![phys(1, 1, 20_000)]; + let (p_sdb, r_sdb) = + plan_disk_layout("/dev/sdb", 40_000, &sdb, &[], &[(0, 1_000)]).expect("sdb"); + + // sda'da 200–40000 boş: yeni bölüm oraya gider, numarası 2 + assert_eq!(p_sda[0].device, "/dev/sda2"); + assert!(r_sda.is_empty(), "sda1 zaten 1, kayma yok"); + + // sdb HİÇ ETKİLENMEMELİ + assert_eq!(p_sdb[0].device, "/dev/sdb2"); + assert!(r_sdb.is_empty(), "sdb numaraları sda'dan bağımsız"); + } + + /// NVMe/MMC adlandırması: sda1 değil sda1p1 değil — part_path'e bırakıldı. + #[test] + fn nvme_slots_use_p_separator() { + let physical = vec![phys(1, 1, 100)]; + let (planned, _) = plan_disk_layout("/dev/nvme0n1", 10_000, &physical, &[], &[(0, 500)]) + .expect("planlanmalı"); + assert_eq!(planned[0].device, "/dev/nvme0n1p2"); + } +} diff --git a/src/steps/partition.rs b/src/steps/partition.rs index 0fd036c..4ecd93c 100644 --- a/src/steps/partition.rs +++ b/src/steps/partition.rs @@ -241,6 +241,16 @@ struct ManualState { add_size_str: String, /// Diyalog: bağlama noktası add_mountpoint: String, + /// Diyalog: bölümün HEDEF DİSKİ (ör. "/dev/sdb"). + /// + /// Çoklu disk kurulumunda kritik: / kök sda'da iken /home'u sdb'ye + /// koyabilmek bu alanla mümkün. Daha önce yeni bölümün diski + /// `state.selected_disk`'e sabitleniyordu ve diyalogda hiçbir disk + /// kontrolü yoktu — kullanıcı sdb'ye bölüm koymak istiyorsa + /// kurulumun GRUB hedefini de değiştirmek zorundaydı. + /// + /// Boşsa `state.selected_disk` kullanılır (geriye uyum). + add_disk: String, /// Diyalog: LUKS şifrele add_encrypt: bool, /// Diyalog: LUKS parola @@ -336,15 +346,34 @@ fn make_plan(disk: &DiskInfo, is_uefi: bool) -> Option { /// Manuel listedeki bölümlerin geçerliliğini kontrol eder. /// Silinmek işaretli bölümler kurulumda var olmayacağı için sayılmaz. +#[cfg(test)] fn validate_manual( parts: &[CustomPartition], lvs: &[crate::installer::LogicalVolume], is_uefi: bool, +) -> Option { + validate_manual_on(parts, lvs, is_uefi, &[]) +} + +/// `validate_manual` + disk varlığı kontrolü. +/// +/// `disks` verildiğinde her tanımın diski listede GERÇEKTEN var mı +/// diye bakılır. Bu ayrı bir kontroldür çünkü "bilinmeyen disk" ile +/// "kapasite aşıldı" farklı hatalardır: biri aygıt seçimiyle, diğeri +/// boyutla ilgilidir. `disks` boş geçilirse bu kontrol atlanır +/// (saf bölüm kuralları için — testler böyle çağırır). +fn validate_manual_on( + parts: &[CustomPartition], + lvs: &[crate::installer::LogicalVolume], + is_uefi: bool, + disks: &[DiskInfo], ) -> Option { let live = |p: &&CustomPartition| !p.delete; - let has_root = parts.iter().filter(live).any(|p| p.mountpoint == "/") + let live_parts: Vec<&CustomPartition> = parts.iter().filter(live).collect(); + + let has_root = live_parts.iter().any(|p| p.mountpoint == "/") || lvs.iter().any(|lv| lv.mountpoint == "/"); - let has_efi = parts.iter().filter(live) + let has_efi = live_parts.iter() .any(|p| p.mountpoint == "/boot/efi" || p.fstype == FsType::Fat32); if !has_root { @@ -354,8 +383,36 @@ fn validate_manual( return Some(t!("mp_no_efi").to_string()); } + // Disk başına yalnızca BİR "/" olabilir — aynı diske iki kök ya da + // iki farklı diske kök, mount sırası belirsizleşir. + let roots: Vec<&CustomPartition> = live_parts + .iter() + .copied() + .filter(|p| p.mountpoint == "/") + .collect(); + if roots.len() > 1 { + let disks: Vec<&str> = roots.iter().map(|p| p.disk.as_str()).collect(); + return Some(t!("mp_multiple_root", disks = disks.join(", ")).to_string()); + } + + // Her tanımın diski atanmış olmalı. Boş disk alanı kurulumda + // "olmayan aygıt" hatası verir. + if live_parts.iter().any(|p| p.disk.is_empty()) { + return Some(t!("mp_partition_no_disk").to_string()); + } + + // Disk gerçekten bağlı mı? (disks listesi sağlandıysa) + if !disks.is_empty() { + if let Some(missing) = live_parts + .iter() + .map(|p| p.disk.as_str()) + .find(|d| !disks.iter().any(|x| x.name == *d)) + { + return Some(t!("mp_disk_not_found", disk = missing).to_string()); + } + } + // Aynı mountpoint'ta iki bölüm tanımlı mı kontrolü - let live_parts: Vec<&CustomPartition> = parts.iter().filter(live).collect(); let mountpoints: std::collections::HashSet<&str> = live_parts.iter().map(|p| p.mountpoint.as_str()).collect(); if mountpoints.len() != live_parts.len() { @@ -372,12 +429,11 @@ fn validate_manual( } } - // Boyut 0 ("kalan alan") tanımın mountpoint'i boşsa anlamsız + // Mountpoint boş olmamalı — TEK istisna LVM PV'dir (üzerinde + // mantıksal hacimler yaşar, kendisi bağlanmaz). for p in &live_parts { - if p.size_mb == 0 && p.mountpoint.is_empty() { - return Some(format!( - "{} boyut 0 (kalan alan) belirtilmiş ama mountpoint boş", p.device - )); + if p.mountpoint.is_empty() && p.fstype != FsType::Lvm { + return Some(t!("mp_error_empty_mountpoint").to_string()); } } @@ -409,18 +465,43 @@ fn used_mb_on_disk(parts: &[CustomPartition], disk: &str, exclude_idx: Option Option { - for d in disks { + // Tanımda geçen diskleri, tanım sırasına göre (tekrarsız). + let mut target_disks: Vec<&str> = Vec::new(); + for p in parts.iter().filter(|p| !p.delete) { + if !p.disk.is_empty() && !target_disks.contains(&p.disk.as_str()) { + target_disks.push(p.disk.as_str()); + } + } + + for d in target_disks { let defs: Vec<&CustomPartition> = parts.iter() - .filter(|p| p.disk == d.name && !p.delete) + .filter(|p| p.disk == d && !p.delete) .collect(); if defs.is_empty() { continue; } - let cap_mb = d.size_bytes / 1_048_576; + + // Disk bilgisi listede yoksa boyut 0 kabul edilir; bu durum + // ayrıca validate_manual'da "disk bulunamadı" olarak bildirilir. + // Disk bilgisi yoksa burada KAPASİTE HESABI YAPILMAZ. "Disk + // bulunamadı" ayrı bir hatadır ve `validate_manual` onu + // bildirir; 0 MB kapasiteyle "aşıyor" demek yanıltıcı olur. + let cap_mb = match disks.iter().find(|x| x.name == d) { + Some(x) => x.size_bytes / 1_048_576, + None => continue, + }; + let fixed_mb: u64 = defs.iter().map(|p| p.size_mb).sum(); if fixed_mb > cap_mb { return Some(format!( "{}: sabit tanımlar ({:.1} GB) disk boyutunu ({:.1} GB) aşıyor", - d.name, fixed_mb as f64 / 1024.0, cap_mb as f64 / 1024.0 + d, fixed_mb as f64 / 1024.0, cap_mb as f64 / 1024.0 )); } } @@ -498,7 +579,12 @@ impl InstallerStep for PartitionStep { } else { !state.custom_partitions.is_empty() && state.selected_disk.is_some() - && validate_manual(&state.custom_partitions, &state.logical_volumes, self.is_uefi).is_none() + && validate_manual_on( + &state.custom_partitions, + &state.logical_volumes, + self.is_uefi, + &state.available_disks, + ).is_none() && manual_capacity_error(&state.custom_partitions, &state.available_disks).is_none() } } @@ -888,17 +974,39 @@ impl PartitionStep { // konumunda TEK bir "Boş Alan" satırında birleşirler (GParted tarzı). enum TableRow<'a> { Part(usize, &'a CustomPartition), - FreeSpace { disk: String, total_mb: u64, first_idx: usize }, + /// `first_idx`: silinmek işaretli tanımların birleştirildiği + /// satırda, işaretli ilk tanımın indeksi (seçilebilir). + /// `None`: disk HİÇ tanım içermiyor (yeni/boş disk) — + /// seçilemez, çünkü indeks karşılığı olan bir satır yok. + FreeSpace { disk: String, total_mb: u64, first_idx: Option }, } let mut visible_rows: Vec = Vec::new(); { - let mut seen_disks: Vec<&str> = Vec::new(); - for part in &state.custom_partitions { - if !seen_disks.contains(&part.disk.as_str()) { - seen_disks.push(&part.disk); + // Hangi diskler gösterilecek? + // + // ÖNEMLİ: yalnızca `custom_partitions` içinde geçen diskler + // listeleniyordu. Bu, hiç bölümü (veya bölüm tablosu bile) + // olmayan yeni bir diski tamamen GİZLİYORDU — kullanıcı + // "sdb'de ne var?" diye bakamıyor, boş-alan satırı göremiyor, + // dolayısıyla o diske bölüm koymanın yolunu da göremiyordu. + // Çoklu disk kurulumunda tam da bu disk gerekiyor. + // + // Bu yüzden önce `available_disks` (taranan tüm diskler) + // listelenir, ardından tanımı olan ama listede olmayan + // diskler (mount vb. ile eklenmiş olabilir) eklenir. + let mut seen_disks: Vec = Vec::new(); + for d in &state.available_disks { + if !seen_disks.contains(&d.name) { + seen_disks.push(d.name.clone()); } } - for d in seen_disks { + for part in &state.custom_partitions { + if !part.disk.is_empty() && !seen_disks.contains(&part.disk) { + seen_disks.push(part.disk.clone()); + } + } + for d in &seen_disks { + let d: &str = d.as_str(); // Boş alan = disk boyutu − (korunan fiziksel + YENİ tanımlar). // Silinenler zaten boşalır; yeni tanımlar da bu boşluktan // yerleşeceği için gösterimden düşülürler. @@ -922,7 +1030,7 @@ impl PartitionStep { if let Some(first) = fs_first.take() { if free_total_mb > 0 { visible_rows.push(TableRow::FreeSpace { - disk: d.to_string(), total_mb: free_total_mb, first_idx: first, + disk: d.to_string(), total_mb: free_total_mb, first_idx: Some(first), }); } } @@ -932,7 +1040,17 @@ impl PartitionStep { if let Some(first) = fs_first.take() { if free_total_mb > 0 { visible_rows.push(TableRow::FreeSpace { - disk: d.to_string(), total_mb: free_total_mb, first_idx: first, + disk: d.to_string(), total_mb: free_total_mb, first_idx: Some(first), + }); + } + } else if state.custom_partitions.iter().all(|p| p.disk != d) { + // Disk HİÇ tanım içermiyor (yeni/boş disk). + // Önceden bu disk tabloda hiç görünmüyordu. + // Kullanıcıya "buraya bölüm ekleyebilirsin" bilgisi + // veren boş-alan satırı basılır. + if free_total_mb > 0 { + visible_rows.push(TableRow::FreeSpace { + disk: d.to_string(), total_mb: free_total_mb, first_idx: None, }); } } @@ -942,8 +1060,8 @@ impl PartitionStep { let total_rows = visible_rows.len(); let mut last_disk_header = String::new(); for (vis_idx, row) in visible_rows.iter().enumerate() { - let (disk_name, global_idx, part): (&str, usize, Option<&CustomPartition>) = match row { - TableRow::Part(gi, p) => (p.disk.as_str(), *gi, Some(p)), + let (disk_name, global_idx, part): (&str, Option, Option<&CustomPartition>) = match row { + TableRow::Part(gi, p) => (p.disk.as_str(), Some(*gi), Some(p)), TableRow::FreeSpace { disk, first_idx, .. } => (disk.as_str(), *first_idx, None), }; // Disk grubu başlığı: yeni diske geçildiğinde bir kez çiz @@ -1036,7 +1154,8 @@ impl PartitionStep { } }; - let is_selected = self.manual.selected_idx == Some(global_idx); + let is_selected = global_idx.is_some() + && self.manual.selected_idx == global_idx; let is_free_row = part.is_none(); let row_bg = if is_free_row { egui::Color32::from_rgba_unmultiplied(180, 50, 40, 70) @@ -1054,8 +1173,17 @@ impl PartitionStep { egui::Sense::click(), ); + // Boş diskin boş-alan satırı seçilemez (first_idx = None): + // indeks 0'a düşürülürse kullanıcı BAŞKA diskin ilk bölümünü + // seçmiş olur ve "Sil" yanlış satıra etki ederdi. if row_response.clicked() { - self.manual.selected_idx = Some(global_idx); + if let Some(idx) = global_idx { + self.manual.selected_idx = Some(idx); + } else { + // Boş disk satırı: seçim yok edilir, yanlış diskteki + // bir bölüme işlem uygulanmasın. + self.manual.selected_idx = None; + } } row_response.on_hover_cursor(egui::CursorIcon::PointingHand); @@ -1219,20 +1347,40 @@ impl PartitionStep { self.manual.editing = false; self.manual.create_over = false; self.manual.edit_idx = None; - // Varsayılan öneriler, AKTİF diskteki tanım sayısına göre yapılır + // Yeni tanımın varsayılan diski: aktif disk. Kullanıcı + // diyalogdaki seçiciyle değiştirebilir. let active_disk = state.selected_disk.clone().unwrap_or_default(); - let next_num = state.custom_partitions.iter() - .filter(|p| p.disk == active_disk) - .count() + 1; + self.manual.add_disk = active_disk.clone(); + // Ö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 = match next_num { - 1 if self.is_uefi => "/boot/efi".to_string(), - 1 => "/".to_string(), - 2 => "swap".to_string(), - 3 => "/".to_string(), - _ => String::new(), + 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() }; // Mountpoint'e göre otomatik boyut ata self.manual.add_size_str = match self.manual.add_mountpoint.as_str() { @@ -1395,17 +1543,111 @@ impl PartitionStep { } } }); + // ── HEDEF DİSK SEÇİCİ ───────────────────────────── + // + // ÇOKLU DİSK KURULUMU İÇİN KRİTİK. + // + // Önceden yeni bölümün diski `state.selected_disk`'e + // SABİTLENİYORDU ve diyalogda hiçbir disk kontrolü + // yoktu. /home'u /dev/sdb'ye koymak isteyen kullanıcı + // yalnızca dışarıdaki ComboBox'u sdb'ye çevirip + // "Yeni Bölüm"'e basabildi — ki bu, aynı zamanda + // GRUB'un nereye kurulacağını da değiştiriyordu. + // + // Artık hedef disk burada, açıkça seçilir. Düzenleme + // modunda da değiştirilebilir: bu, bölümü başka + // diske TAŞIMANIN tek yoludur (veri taşınmaz — + // yalnızca tanımın hedefi değişir). + ui.label(t!("mp_target_disk")); + ui.label( + egui::RichText::new(t!("mp_target_disk_hint")) + .size(11.0) + .color(crate::ui::theme::c_text_dim()), + ); + let disks_for_dialog: Vec = state + .available_disks + .iter() + .map(|d| d.name.clone()) + .collect(); + + // Disk listesi boşsa (henüz taranmadıysa) yalnızca + // aktif diski göster; seçim kırılmasın. + let dialog_options: Vec = if disks_for_dialog.is_empty() { + vec![state.selected_disk.clone().unwrap_or_default()] + } else { + disks_for_dialog.clone() + }; + + if !dialog_options.is_empty() { + let current = if self.manual.add_disk.is_empty() { + let fallback = if is_edit { + state.custom_partitions + .get(self.manual.edit_idx.unwrap_or(usize::MAX)) + .map(|p| p.disk.clone()) + .unwrap_or_default() + } else { + state.selected_disk.clone().unwrap_or_default() + }; + self.manual.add_disk = fallback.clone(); + fallback + } else { + self.manual.add_disk.clone() + }; + + theme::show_combo_box( + ui, + "mp_target_disk_combo", + ¤t, + ui.available_width() * 0.6, + |ui, w| { + for name in &dialog_options { + let disk = state.available_disks.iter().find(|d| d.name == *name); + let label = match disk { + Some(d) => format!( + "{} — {} ({:.1} GB)", + d.name, + if d.model.is_empty() { "?" } else { d.model.as_str() }, + d.size_bytes as f64 / 1_073_741_824.0 + ), + None => name.clone(), + }; + if theme::selectable_value( + ui, + &mut self.manual.add_disk, + name.clone(), + &label, + w, + ) + .changed() + { + // Disk değişince bu diske ait + // olmayan bir mountpoint önerisi + // (ör. başka diskin swap'ı) yanlış + // olabilir; öneriyi sıfırla. + self.manual.add_mountpoint.clear(); + self.manual.add_size_str.clear(); + } + } + }, + ); + } + + ui.add_space(6.0); + ui.separator(); + // Slider — kullanılabilir alan kadar - // Hedef disk: düzenlemede bölümün kendi diski, - // eklemede aktif (seçili) disk. Boyut hesapları - // yalnızca bu disk üzerinden yapılır. - let dialog_disk = if is_edit { + // Hedef disk: yukarıda seçilen disk (düzenlemede + // varsayılan olarak bölümün kendi diski). Boyut + // hesapları YALNIZCA bu disk üzerinden yapılır. + let dialog_disk = if is_edit && self.manual.add_disk.is_empty() { self.manual.edit_idx .and_then(|i| state.custom_partitions.get(i)) .map(|p| p.disk.clone()) .unwrap_or_default() - } else { + } else if self.manual.add_disk.is_empty() { state.selected_disk.clone().unwrap_or_default() + } else { + self.manual.add_disk.clone() }; let disk_total_mb = disk_size_mb(state, &dialog_disk); let used_mb: u64 = used_mb_on_disk(&state.custom_partitions, &dialog_disk, self.manual.edit_idx); @@ -1556,12 +1798,10 @@ impl PartitionStep { ui.horizontal(|ui| { let ok_label = if is_create_like { t!("mp_add_btn") } else { t!("mp_save") }; if theme::primary_button(ui, &ok_label).clicked() { - // Hedef disk: düzenlemede bölümün diski, eklemede aktif disk - let target_disk = if is_edit { - self.manual.edit_idx - .and_then(|i| state.custom_partitions.get(i)) - .map(|p| p.disk.clone()) - .unwrap_or_else(|| disk.clone()) + // Hedef disk: diyalogdaki disk seçiciden gelir. + // Boşsa geriye uyum için aktif diske düşülür. + let target_disk = if !self.manual.add_disk.is_empty() { + self.manual.add_disk.clone() } else { disk.clone() }; @@ -1581,7 +1821,11 @@ impl PartitionStep { let encrypt = self.manual.add_encrypt; let luks_password = if encrypt { self.manual.add_luks_password.clone() } else { String::new() }; - if mp.is_empty() { + // LVM PV'nin bağlama noktası YOKTUR; üzerinde + // mantıksal hacimler yaşar. Diğer her dosya + // sistemi için mountpoint zorunludur. + let is_pv = fstype == FsType::Lvm; + if mp.is_empty() && !is_pv { self.manual.error = Some(t!("mp_error_empty_mountpoint").to_string()); } else if target_disk.is_empty() { self.manual.error = Some(t!("mp_select_disk_first").to_string()); @@ -1589,10 +1833,28 @@ impl PartitionStep { if let Some(idx) = self.manual.edit_idx { if idx < state.custom_partitions.len() { let skip_format = self.manual.add_skip_format; + let disk_moved = state.custom_partitions[idx].disk != target_disk; + // Disk DEĞİŞTİYSE bu tanım artık o + // fiziksel bölümü temsil etmiyor; + // orijinali de tutulamaz, yoksa + // job eski diske bir şey yapmaya + // kalkar. Kullanıcı bölümü taşıyor + // (veri taşınmaz; tanım hedefi değişir). + let orig_device = if disk_moved { + String::new() + } else { + state.custom_partitions[idx].orig_device.clone() + }; state.custom_partitions[idx] = CustomPartition { - disk: state.custom_partitions[idx].disk.clone(), - device: state.custom_partitions[idx].device.clone(), - orig_device: state.custom_partitions[idx].orig_device.clone(), + disk: target_disk.clone(), + // disk değiştiyse cihaz yolu + // yeniden atanmalı + device: if disk_moved { + String::new() + } else { + state.custom_partitions[idx].device.clone() + }, + orig_device, size_mb, fstype, mountpoint: mp, @@ -1883,7 +2145,12 @@ impl PartitionStep { egui::Color32::from_rgb(217, 0, 91), format!("\u{ECA1} {}", warn), ); - } else if let Some(warn) = validate_manual(&state.custom_partitions, &state.logical_volumes, self.is_uefi) { + } else if let Some(warn) = validate_manual_on( + &state.custom_partitions, + &state.logical_volumes, + self.is_uefi, + &state.available_disks, + ) { ui.colored_label( egui::Color32::from_rgb(220, 160, 40), format!("\u{ECA1} {}", warn), @@ -2170,23 +2437,29 @@ fn format_number_thousands(n: u64) -> String { result } -#[cfg(test)] -mod tests { - use super::*; - use crate::installer::{LogicalVolume, PartitionTableType}; - fn disk(name: &str, size_gb: u64) -> DiskInfo { +// ───────────────────────────────────────────── +// ORTAK TEST YARDIMCILARI +// Birden çok test modülü aynı DiskInfo/CustomPartition kurulumunu +// kullanıyor; tek yerde tanımlı. +// ───────────────────────────────────────────── +#[cfg(test)] +pub(crate) mod testutil { + use super::*; + use crate::installer::DiskInfo; + + pub fn disk(name: &str, size_gb: u64) -> DiskInfo { DiskInfo { name: name.to_string(), - vendor: "ATA".to_string(), - model: "VBOX HARDDISK".to_string(), - drive_type: "HDD".to_string(), + vendor: String::new(), + model: "TEST".to_string(), + drive_type: String::new(), size_gb, size_bytes: size_gb * 1_073_741_824, } } - fn part(device: &str, size_mb: u64, fstype: FsType, mountpoint: &str) -> CustomPartition { + pub fn part(device: &str, size_mb: u64, fstype: FsType, mountpoint: &str) -> CustomPartition { CustomPartition { disk: crate::installer::device_to_disk(device), device: device.to_string(), @@ -2201,6 +2474,15 @@ mod tests { delete: false, } } +} + +#[cfg(test)] +mod tests { + use super::*; + use super::testutil::{disk, part}; + use crate::installer::{LogicalVolume, PartitionTableType}; + + fn step(uefi: bool) -> PartitionStep { PartitionStep { @@ -2490,3 +2772,241 @@ mod tests { } } } + +#[cfg(test)] +mod multidisk_tests { + use super::*; + use super::testutil::disk; + + fn p(disk: &str, device: &str, size_mb: u64, fstype: FsType, mountpoint: &str) -> CustomPartition { + CustomPartition { + disk: disk.to_string(), + device: device.to_string(), + orig_device: device.to_string(), + size_mb, + fstype, + mountpoint: mountpoint.to_string(), + encrypt: false, + luks_password: String::new(), + luks_name: String::new(), + skip_format: true, + delete: false, + } + } + + /// Kullanıcının asıl istediği senaryo: / kök sda'da, /home sdb'de. + /// Bu GEÇERLİ bir düzen olmalı. + #[test] + fn root_on_one_disk_and_home_on_another_is_valid() { + let disks = vec![ + disk("/dev/sda", 100), + disk("/dev/sdb", 100), + ]; + let parts = vec![ + p("/dev/sda", "/dev/sda1", 300, FsType::Fat32, "/boot/efi"), + p("/dev/sda", "/dev/sda2", 40_000, FsType::Ext4, "/"), + p("/dev/sdb", "/dev/sdb1", 50_000, FsType::Ext4, "/home"), + ]; + + assert!( + validate_manual_on(&parts, &[], true, &disks).is_none(), + "çoklu disk düzeni geçerli olmalı: {:?}", + validate_manual_on(&parts, &[], true, &disks) + ); + assert!( + manual_capacity_error(&parts, &disks).is_none(), + "her diskin kapasitesi kendi tanımlarıyla kontrol edilmeli" + ); + } + + /// İki kök tanımı geçersiz — mount sırası belirsiz. + #[test] + fn two_roots_are_rejected() { + let disks = vec![disk("/dev/sda", 100), disk("/dev/sdb", 100)]; + let parts = vec![ + p("/dev/sda", "/dev/sda1", 20_000, FsType::Ext4, "/"), + p("/dev/sdb", "/dev/sdb1", 20_000, FsType::Ext4, "/"), + ]; + let err = validate_manual_on(&parts, &[], true, &disks); + assert!(err.is_some(), "iki kök reddedilmeli"); + } + + /// Çıkarılmış bir diske yapılan tanım yakalanmalı. + #[test] + fn definition_on_detached_disk_is_rejected() { + let disks = vec![disk("/dev/sda", 100)]; + let parts = vec![ + p("/dev/sda", "/dev/sda1", 20_000, FsType::Ext4, "/"), + p("/dev/sdz", "/dev/sdz1", 5_000, FsType::Ext4, "/home"), + ]; + let err = validate_manual_on(&parts, &[], true, &disks) + .expect("bağlı olmayan disk yakalanmalı"); + assert!(err.contains("sdz") || err.len() > 0, "{}", err); + } + + /// Disk alanı boş bırakılmış bir tanım reddedilmeli. + #[test] + fn definition_without_disk_is_rejected() { + let disks = vec![disk("/dev/sda", 100)]; + let mut bad = p("/dev/sda", "/dev/sda1", 20_000, FsType::Ext4, "/"); + bad.disk = String::new(); + let parts = vec![bad]; + assert!( + validate_manual_on(&parts, &[], true, &disks).is_some(), + "disksiz tanım reddedilmeli" + ); + } + + /// REGRESYON: kapasite kontrolü `available_disks` üzerinde yürütülüyordu. + /// O listede olmayan diske yapılan tanım hiç kontrol edilmeden geçiyordu. + #[test] + fn capacity_is_checked_per_definition_disk_not_only_available_disks() { + // sdb listede YOK ama tanım var → eski kod bunu hiç kontrol etmezdi. + let disks = vec![disk("/dev/sda", 100)]; + let parts = vec![ + p("/dev/sda", "/dev/sda1", 20_000, FsType::Ext4, "/"), + p("/dev/sdb", "/dev/sdb1", 500_000, FsType::Ext4, "/home"), + ]; + // Kapasite kontrolü bilinmeyen diski atlar (validate_manual yakalar), + // ama listede OLAN disk için doğru çalışır. + assert!(manual_capacity_error(&parts, &disks).is_none()); + assert!(validate_manual_on(&parts, &[], true, &disks).is_some()); + } + + /// sda'da kapasite aşımı → hata, diskin adı belirtilerek. + #[test] + fn over_capacity_names_the_offending_disk() { + let disks = vec![disk("/dev/sda", 10), disk("/dev/sdb", 100)]; + let parts = vec![ + p("/dev/sda", "/dev/sda1", 40_000, FsType::Ext4, "/"), + p("/dev/sdb", "/dev/sdb1", 10_000, FsType::Ext4, "/home"), + ]; + let err = manual_capacity_error(&parts, &disks) + .expect("10 GB diske 40 MB tanım aşım sayılmalı"); + assert!(err.contains("/dev/sda"), "{}", err); + } + + /// iki diskin kapasitesi birbirine karışmamalı. + #[test] + fn capacity_does_not_leak_between_disks() { + let disks = vec![disk("/dev/sda", 10), disk("/dev/sdb", 200)]; + // sda'da 8 GB, sdb'de 150 GB → ikisi de kendi diskine sığıyor + let parts = vec![ + p("/dev/sda", "/dev/sda1", 8_192, FsType::Ext4, "/"), + p("/dev/sdb", "/dev/sdb1", 150_000, FsType::Ext4, "/home"), + ]; + assert!( + manual_capacity_error(&parts, &disks).is_none(), + "her disk kendi kapasitesine göre değerlendirilmeli" + ); + } + + /// LVM PV'nin mountpoint'i boş olabilir — üzerinde mantıksal + /// hacimler yaşar, kendisi bağlanmaz. Diğer dosya sistemlerinde + /// boş mountpoint geçersizdir. + #[test] + fn lvm_pv_may_have_no_mountpoint_but_others_may_not() { + let disks = vec![disk("/dev/sda", 100)]; + + // PV: mountpoint yok → geçerli + let mut pv = p("/dev/sda", "/dev/sda2", 20_000, FsType::Lvm, ""); + pv.orig_device = String::new(); + let with_pv = vec![ + p("/dev/sda", "/dev/sda1", 300, FsType::Fat32, "/boot/efi"), + p("/dev/sda", "/dev/sda3", 40_000, FsType::Ext4, "/"), + pv, + ]; + assert!( + validate_manual_on(&with_pv, &[], true, &disks).is_none(), + "mountpointsiz LVM PV geçerli olmalı: {:?}", + validate_manual_on(&with_pv, &[], true, &disks) + ); + + // Normal bölüm: mountpoint yok → geçersiz + let no_mp = vec![ + p("/dev/sda", "/dev/sda1", 20_000, FsType::Ext4, "/"), + p("/dev/sda", "/dev/sda2", 5_000, FsType::Ext4, ""), + ]; + assert!( + validate_manual_on(&no_mp, &[], true, &disks).is_some(), + "mountpointsiz normal bölüm reddedilmeli" + ); + } + + /// numaralandırma: iki diskte bağımsız slot dağıtımı. + /// ESKİ test (renumber_keeps_disks_independent) yalnızca fiziksel + /// bölümleri kullandığı için TUUTOLOJİK'ydi: global (disk-suz) + /// bir uygulama da testi geçerdi. Burada YENİ tanımlar var, yani + /// asıl çapraz-disk yolu sınanıyor. + #[test] + fn new_definitions_get_independent_slots_per_disk() { + let mut parts = vec![ + // sda: 1 fiziksel bölüm korunuyor + p("/dev/sda", "/dev/sda1", 30_000, FsType::Ext4, "/"), + // sdb: 1 fiziksel bölüm korunuyor + p("/dev/sdb", "/dev/sdb1", 30_000, FsType::Ext4, "/home"), + ]; + // Her diske bir yeni tanım ekle + parts.push(CustomPartition { + disk: "/dev/sda".into(), device: String::new(), orig_device: String::new(), + size_mb: 5_000, fstype: FsType::Ext4, mountpoint: "/var".into(), + encrypt: false, luks_password: String::new(), luks_name: String::new(), + skip_format: false, delete: false, + }); + parts.push(CustomPartition { + disk: "/dev/sdb".into(), device: String::new(), orig_device: String::new(), + size_mb: 5_000, fstype: FsType::Ext4, mountpoint: "/opt".into(), + encrypt: false, luks_password: String::new(), luks_name: String::new(), + skip_format: false, delete: false, + }); + + assign_partition_numbers(&mut parts); + + // Her iki yeni tanım da kendi diskinde 2 numarasını almalı + let sda_new: Vec<&CustomPartition> = parts.iter() + .filter(|x| x.disk == "/dev/sda" && x.orig_device.is_empty()).collect(); + let sdb_new: Vec<&CustomPartition> = parts.iter() + .filter(|x| x.disk == "/dev/sdb" && x.orig_device.is_empty()).collect(); + + assert_eq!(sda_new.len(), 1); + assert_eq!(sdb_new.len(), 1); + assert_eq!(sda_new[0].device, "/dev/sda2", + "sda'daki yeni tanım sda2 olmalı"); + assert_eq!(sdb_new[0].device, "/dev/sdb2", + "sdb'deki yeni tanım sdb2 olmalı"); + } + + /// sda'da silme varsa slot oradaki yeni tanımı etkiler, sdb'yi ETKİLEMEZ. + #[test] + fn delete_on_one_disk_does_not_free_slots_on_another() { + let mut parts = vec![ + p("/dev/sda", "/dev/sda1", 10_000, FsType::Ext4, "/"), + p("/dev/sda", "/dev/sda2", 10_000, FsType::Ext4, ""), // silinecek + p("/dev/sdb", "/dev/sdb1", 10_000, FsType::Ext4, "/home"), + p("/dev/sdb", "/dev/sdb2", 10_000, FsType::Ext4, ""), // korunacak + ]; + parts[1].delete = true; + + parts.push(CustomPartition { + disk: "/dev/sda".into(), device: String::new(), orig_device: String::new(), + size_mb: 5_000, fstype: FsType::Ext4, mountpoint: "/var".into(), + encrypt: false, luks_password: String::new(), luks_name: String::new(), + skip_format: false, delete: false, + }); + + assign_partition_numbers(&mut parts); + + let sda_new = parts.iter() + .find(|x| x.disk == "/dev/sda" && x.orig_device.is_empty()) + .expect("sda yeni tanım"); + assert_eq!(sda_new.device, "/dev/sda2", + "sda'da silinen slot 2 boşaldı, yeni tanım oraya alınmalı"); + + // sdb'de hiçbir şey silinmedi: sdb2 KORUNMALI + let sdb_kept = parts.iter() + .find(|x| x.disk == "/dev/sdb" && x.orig_device == "/dev/sdb2") + .expect("sdb2"); + assert_eq!(sdb_kept.device, "/dev/sdb2", + "sdb'de silme olmadığı için numaralar değişmemeli"); + } +}