fix(partition): çoklu disk kurulumu — veri kaybı hatası, disk seçici, doğrulama

Kullanıcı Linux bölümlerini ayrı disklere dağıtabilmeli. Veri modeli
(ö. `CustomPartition.disk`) çoklu diski destekliyordu; UI ve job
katmanındaki boşluklar bu işi kullanamaz hale getiriyordu.

## 1. VERİ KAYBI: bölüm numaraları ayrışıyordu (jobs/partition.rs)

Asıl bulgu. Bölüm numarası İKİ AYRI YERDE bağımsız hesaplanıyordu:
  - UI: assign_partition_numbers → "en küçük boş slot"
  - Job: mkpart + part.device okuması

`parted rm` silinen bölümden sonrakileri kaydırır ve mkpart yeni
bölümü başlangıç offset'ine göre yerleştirir. Aradaki boşluk
istenen boyuta SIĞMADIĞINDA mkpart diskin sonuna yazıyor, kernel yeni
bölüme 3 veriyor — UI ise 2 demişti. FAZ C mkfs'i o aygıta
uyguluyordu, yani kullanıcının KORUMAK İSTEDİĞİ bölüm
biçimlendiriliyordu.

  - `plan_disk_layout()`: saf, test edilebilir bir fonksiyon. Diskin
    tamamını (fiziksel + yeni) 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ını döndürüyor.
  - FAZ B artık tahmin yürütmüyor, bu planı kullanıyor.
  - FAZ C `device_map` üzerinden GERÇEK aygıtı kullanıyor; kayma
    olduğunda loglayıp doğru aygıta işliyor.
  - `disk_free_regions_mb` (aynı mantığın eski, ayrı kopyası) silindi.
  - `disk_total_mb` ve `read_physical_extents` eklendi; artık diskin
    gerçek boyutunu sysfs'ten okuyor (önce son bölümün bitişinden
    tahmin yürütülüyordu).

11 test, bunların içinde asıl regresyonu kilitleyen:
  delete_plus_create_renumbers_physical_partitions,
  deleting_on_one_disk_does_not_renumber_another,
  layouts_are_independent_per_disk.

## 2. Ekleme diyaloğunda disk seçici YOKTU (steps/partition.rs)

Yeni bölümün diski `state.selected_disk`'e SABİTLENİYORDU ve diyalogda
hiçbir disk kontrolü bulunmuyordu. /home'u sdb'ye koymak isteyen
kullanıcı, kurulumun GRUB hedefini de değiştirmek zorundaydı
(`selected_disk` aynı zamanda `build_custom_job_queue`'un girdisi).

  - `ManualState::add_disk` + diyaloga disk seçici
  - Düzenlemede de değiştirilebilir: bölümü başka diske taşımanın
    yolu. Taşınırsa `orig_device` temizlenir, yoksa job eski diske
    işlem yapmayı sürdürürdü.
  - Varsayılan öneriler (EFI/root/swap) artık AKTİF diskin durumuna
    göre veriliyor; önce her zaman aktif diskin bölüm sayımı
    kullanılıyordu, yeni diske geçince alakasız öneri çıkıyordu.

## 3. Hiç bölümü olmayan diskler tabloda GİZLİYDİ (steps/partition.rs)

`seen_disks` yalnızca `custom_partitions` içinden kuruluyordu. Yeni
veya bölüm tablosu olmayan bir disk ne başlık ne boş-alan satırı
gösteriyordu — kullanıcı "sdb'de ne var?" diye bakamıyor, dolayısıyla
oraya bölüm koymanın yolunu da göremiyordu. Artık `available_disks`
taranıyor ve tanımsız disklere boş-alan satırı basılıyor.

Bu satır `first_idx: None` taşıyor: tıklanırsa `selected_idx = 0`
olur ve kullanıcı BAŞKA diskin ilk bölümü seçilmiş olurdu.

## 4. Kapasite doğrulaması yanlış listeyi geziyordu (steps/partition.rs)

`manual_capacity_error` `available_disks` üzerinde yürüyordu; o
listede olmayan diske yapılan tanım hiç kontrol edilmeden geçiyordu.
Artık bölüm tanımlarının kendi `disk` alanından yürüyor.

Ayrıca "disk bağlı değil" ile "kapasite aşıldı" ayrıldı:
validate_manual_on(..., &disks) diskin gerçekten var olduğunu
kontrol ediyor, kapasite hesabı yalnızca bilinen disklerde yapılıyor
(0 MB kapasiteyle "aşıyor" demek yanıltıcıydı).

Yeni kontroller: birden fazla kök (/), disksiz tanım, bağlı olmayan
disk.

## 5. LVM PV mount ediliyordu (jobs/partition.rs)

Diyalog her tanım için mountpoint zorunlu kılıyordu; PV'nin
mountpoint'i vardı, MountCustomPartitionsJob onu mount'lamaya
çalışıyordu ("wrong fs type" → kurulumun tamamı düşüyordu) ve
FstabFromCustomPartitionsJob fstab'a ext4 satırı yazıyordu.
Artık PV mount edilmiyor, fstab'a girmiyor, mountpoint'i de
zorunlu değil.

## 6. prepare_disk seyrek düzende bölüm çözmüyordu (jobs/partition.rs)

`1..=32` numaralandırılıp İLK EKSİK bölümde `break` ediliyordu.
sda1 + sda3 olan bir diskte döngü n=2'de duruyor, sda3 hiç çözülmüyor
→ bağlı kalan bölüm yüzünden `parted mkpart` "device busy" veriyordu.
Artık gerçek bölümler lsblk'den alınıyor.

## 7. GRUB yedek yapılandırması kökü tahmin ediyordu (jobs/bootloader.rs)

generate_minimal_grub_cfg kök aygıtını `is_uefi` ile (3|2) ve seçili
disk üzerinden buluyordu. Manuel modda numaralar
assign_partition_numbers'tan gelir, üstelik kök başka bir diskte
olabilir. Artık `mounted_root_device()` ile mount noktasının arkasındaki
gerçek aygıt soruluyor. BIOS'taki sabit `set root=(hd0,msdos2)` de
gerçek kök aygıtının bölüm numarasından geliyor.

## Testler
+22: 11 plan/numaralandırma, 10 çoklu disk doğrulama, 1 LVM PV.
steps/partition.rs'te disk() ve part() yardımcıları iki test modülü
ortak kullandığı için testutil modülüne taşındı.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erkan IŞIK
2026-09-25 23:54:27 +03:00
parent 8d652c3a99
commit 4c68c5018e
5 changed files with 1210 additions and 183 deletions
+5
View File
@@ -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." display_manager_note = "Note: The display manager will be enabled by default on the installed system."
mp_size_remaining = "Remaining Space" mp_size_remaining = "Remaining Space"
mp_size_available = "Available" 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_create_here = "Create Here"
mp_will_delete = "will be deleted" mp_will_delete = "will be deleted"
mp_restore = "Restore" mp_restore = "Restore"
mp_free_space = "Free Space" mp_free_space = "Free Space"
mp_duplicate_mountpoint = "Two partitions share the same mount point; each partition must have a distinct mount point" 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_now = "Current Layout"
mp_bar_after = "After Installation" mp_bar_after = "After Installation"
+5
View File
@@ -119,11 +119,16 @@ summary_partition_plan = "Bölümleme"
summary_custom_partitions = "Manuel Bölümler" summary_custom_partitions = "Manuel Bölümler"
mp_size_remaining = "Kalan Alan" mp_size_remaining = "Kalan Alan"
mp_size_available = "Kullanılabilir" 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_create_here = "Buraya Oluştur"
mp_will_delete = "silinecek" mp_will_delete = "silinecek"
mp_restore = "Geri Al" mp_restore = "Geri Al"
mp_free_space = "Boş Alan" 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_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_now = "Mevcut Durum"
mp_bar_after = "Kurulum Sonrası" mp_bar_after = "Kurulum Sonrası"
+49 -5
View File
@@ -47,6 +47,36 @@ async fn luks_backing_device(name: &str) -> Result<String, String> {
}) })
} }
/// 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<String, String> {
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). /// initramfs yeniler (GRUB öncesi zorunlu).
/// PisiLinux `dracut` kullanıyorsa `dracut -f --regenerate-all`, /// PisiLinux `dracut` kullanıyorsa `dracut -f --regenerate-all`,
/// mkinitcpio kullanıyorsa `mkinitcpio -P`. /// mkinitcpio kullanıyorsa `mkinitcpio -P`.
@@ -498,10 +528,18 @@ async fn generate_minimal_grub_cfg(
tokio::fs::create_dir_all(&grub_dir).await tokio::fs::create_dir_all(&grub_dir).await
.map_err(|e| format!("{} oluşturulamadı: {}", grub_dir, e))?; .map_err(|e| format!("{} oluşturulamadı: {}", grub_dir, e))?;
let (root_num, _swap_num) = if is_uefi { (3u32, 2u32) } else { (2u32, 1u32) }; // Kök aygıtı TAHMİN EDİLMEZ.
let root_part = part_path(disk, root_num); //
let root_uuid = blkid_uuid(&root_part).await // Daha önce `is_uefi` ile (3 | 2) deniyor ve `disk` (seçili disk)
.map_err(|e| format!("Kök UUID okunamadı: {}", e))?; // ü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<String> = None; let mut kernel_file: Option<String> = None;
let mut initrd_files: Vec<String> = Vec::new(); let mut initrd_files: Vec<String> = 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!(" search --no-floppy --fs-uuid --set=root {}\n", root_uuid));
cfg.push_str(&format!(" linux /boot/{} root=UUID={} rw quiet", kernel, root_uuid)); cfg.push_str(&format!(" linux /boot/{} root=UUID={} rw quiet", kernel, root_uuid));
} else { } 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("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)); cfg.push_str(&format!(" linux /boot/{} root=UUID={} rw quiet", kernel, root_uuid));
} }
+571 -118
View File
@@ -1,13 +1,287 @@
use std::collections::HashMap;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use tokio::process::Command; use tokio::process::Command;
use crate::installer::{CustomPartition, FsType, PartitionPlan, PartitionTableType}; use crate::installer::{CustomPartition, FsType, PartitionPlan, PartitionTableType};
use super::{blkid_uuid, part_path, run_cmd, DEMO_MODE, Job, UiSender}; 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<PlannedCreate>, Vec<(u32, String)>), String> {
// 1. Silinmeyen fiziksel bölümler
let mut survivors: Vec<(u64, u64, Option<u32>)> = 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<PlannedCreate> = 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<usize, u32> = 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<u32>)], 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 // YARDIMCI: DISK / BÖLÜMLEME KOMUTLARI
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
/// Diskin toplam boyutunu MB cinsinden okur (sysfs).
fn disk_total_mb(disk: &str) -> Result<u64, String> {
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<Vec<PhysicalExtent>, String> {
const SECTOR: u64 = 512;
const MB: u64 = 1024 * 1024;
let base = disk.trim_start_matches("/dev/");
let mut out: Vec<PhysicalExtent> = 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` komutlarını çalıştırmak için özel sarmalayıcı.
/// ///
/// `parted -s` canlı sistemde kullanımda olan bir disk üzerinde bölüm /// `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 // 1. Diskteki aktif swap'ları kapat
ui.log(format!("Swap ayrılıyor: {}…", disk)); 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]) .args(["-o", "NAME,TYPE", "-l", "--noheadings", disk])
.output().await; .output().await
if let Ok(out) = lsblk { .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
let text = String::from_utf8_lossy(&out.stdout); .unwrap_or_default();
for line in text.lines() { {
for line in lsblk_out.lines() {
if line.contains("part") { if line.contains("part") {
if let Some(name) = line.split_whitespace().next() { if let Some(name) = line.split_whitespace().next() {
let dev = format!("/dev/{}", name); 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) // 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)); ui.log(format!("Bağlantılar ayrılıyor: {}…", disk));
for n in 1..=32u32 { for line in lsblk_out.lines() {
let part = crate::installer::part_path(disk, n); if !line.contains("part") { continue; }
if !std::path::Path::new(&part).exists() { break; } let Some(name) = line.split_whitespace().next() else { continue };
let dev = format!("/dev/{}", name);
let _ = tokio::process::Command::new("umount") let _ = tokio::process::Command::new("umount")
.args(["-lf", &part]).status().await; .args(["-lf", &dev]).status().await;
} }
// 3. İlk senkronizasyon // 3. İlk senkronizasyon
@@ -466,64 +751,6 @@ async fn read_partition_uuids(
// MANUEL BÖLÜMLEME İÇİN DESTEKLEYİCİ JOB'LAR // 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<Vec<(u64, u64)>, 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) /// Manuel bölümleme ayarlarını granular (eski Yali Operation modeli)
/// yaklaşımla uygulayan Job. /// 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. // MBR'de "primary" zorunludur, GPT'de ise bu alan isim (label) alanıdır.
let part_type_or_name = if is_uefi { "" } else { "primary" }; 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<String, String> = HashMap::new();
// 0. İşlem yapılan diskleri tanım sırasına göre topla // 0. İşlem yapılan diskleri tanım sırasına göre topla
let mut disks: Vec<String> = Vec::new(); let mut disks: Vec<String> = Vec::new();
for part in &self.custom_partitions { for part in &self.custom_partitions {
@@ -597,9 +832,53 @@ impl Job for CustomPartitionJob {
} }
// ── FAZ B: YENİ BÖLÜMLER (boş alana yerleştir) ────────────── // ── 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() { if !creates.is_empty() {
let mut regions = disk_free_regions_mb(disk, &destroys)?; let total_mb = disk_total_mb(disk)?;
for part in &creates { 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<usize> = 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 { let fs_label = match part.fstype {
FsType::Fat32 => "fat32", FsType::Fat32 => "fat32",
FsType::Swap => "linux-swap", FsType::Swap => "linux-swap",
@@ -613,58 +892,17 @@ impl Job for CustomPartitionJob {
FsType::LvmLv => continue, 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; // DİKKAT: parted "MB" sonekini onalık (10^6) okur;
// hesaplarımız binary (2^20) olduğu için "MiB" zorunlu. // hesaplarımız binary (2^20) olduğu için "MiB" zorunlu.
// Tamsayı MiB = 2048 sektörün katı → parted hizalama // Tamsayı MiB = 2048 sektörün katı → parted hizalama
// hatası vermez. // hatası vermez.
let start_str = format!("{}MiB", start_mb); let start_str = format!("{}MiB", plan.start_mb);
let end_str = format!("{}MiB", end_mb); let end_str = format!("{}MiB", plan.end_mb);
let slot = crate::installer::part_number(&part.device).unwrap_or(0); let slot_str = plan.num.to_string();
ui.log(format!( ui.log(format!(
"{} üzerinde yeni bölüm oluşturuluyor: {} - {} ({})", "{} üzerinde yeni bölüm oluşturuluyor: {} - {} ({}, slot {})",
disk, start_str, end_str, fs_label disk, start_str, end_str, fs_label, plan.num
)); ));
run_parted(&[ run_parted(&[
"-s", disk, "mkpart", part_type_or_name, fs_label, "-s", disk, "mkpart", part_type_or_name, fs_label,
@@ -672,7 +910,7 @@ impl Job for CustomPartitionJob {
]).await?; ]).await?;
if part.mountpoint == "/boot/efi" || part.fstype == FsType::Fat32 { 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; sync_partition_table(disk).await;
@@ -685,7 +923,18 @@ impl Job for CustomPartitionJob {
// Silinmek işaretli bölümlere hiç dokunulmaz. // Silinmek işaretli bölümlere hiç dokunulmaz.
for part in &self.custom_partitions { for part in &self.custom_partitions {
if part.delete { continue; } 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) // LUKS şifreleme (kök veya isteğe bağlı bölümler)
// Şifre, shell injection'ı önlemek için doğrudan stdin pipe üzerinden // Şifre, shell injection'ı önlemek için doğrudan stdin pipe üzerinden
@@ -708,7 +957,7 @@ impl Job for CustomPartitionJob {
format!("/dev/mapper/{}", luks_name) format!("/dev/mapper/{}", luks_name)
} else { } else {
path.clone() path.to_string()
}; };
if part.skip_format { if part.skip_format {
@@ -851,6 +1100,14 @@ impl Job for MountCustomPartitionsJob {
let mut mount_items = Vec::new(); let mut mount_items = Vec::new();
for part in &self.custom_partitions { 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 { if part.mountpoint != "/" && part.fstype != FsType::Swap && !part.mountpoint.is_empty() && !part.delete {
mount_items.push(MountItem { mount_items.push(MountItem {
device: self.device_path(part), device: self.device_path(part),
@@ -912,6 +1169,14 @@ impl Job for FstabFromCustomPartitionsJob {
for part in &self.custom_partitions { for part in &self.custom_partitions {
if part.mountpoint.is_empty() || part.delete { continue; } 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(); let is_encrypted = part.encrypt && !part.luks_password.is_empty();
@@ -1008,3 +1273,191 @@ impl Job for FstabFromCustomPartitionsJob {
Ok(()) 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");
}
}
+580 -60
View File
@@ -241,6 +241,16 @@ struct ManualState {
add_size_str: String, add_size_str: String,
/// Diyalog: bağlama noktası /// Diyalog: bağlama noktası
add_mountpoint: String, 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 /// Diyalog: LUKS şifrele
add_encrypt: bool, add_encrypt: bool,
/// Diyalog: LUKS parola /// Diyalog: LUKS parola
@@ -336,15 +346,34 @@ fn make_plan(disk: &DiskInfo, is_uefi: bool) -> Option<PartitionPlan> {
/// Manuel listedeki bölümlerin geçerliliğini kontrol eder. /// Manuel listedeki bölümlerin geçerliliğini kontrol eder.
/// Silinmek işaretli bölümler kurulumda var olmayacağı için sayılmaz. /// Silinmek işaretli bölümler kurulumda var olmayacağı için sayılmaz.
#[cfg(test)]
fn validate_manual( fn validate_manual(
parts: &[CustomPartition], parts: &[CustomPartition],
lvs: &[crate::installer::LogicalVolume], lvs: &[crate::installer::LogicalVolume],
is_uefi: bool, is_uefi: bool,
) -> Option<String> {
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<String> { ) -> Option<String> {
let live = |p: &&CustomPartition| !p.delete; 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 == "/"); || 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); .any(|p| p.mountpoint == "/boot/efi" || p.fstype == FsType::Fat32);
if !has_root { if !has_root {
@@ -354,8 +383,36 @@ fn validate_manual(
return Some(t!("mp_no_efi").to_string()); 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ü // 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> = let mountpoints: std::collections::HashSet<&str> =
live_parts.iter().map(|p| p.mountpoint.as_str()).collect(); live_parts.iter().map(|p| p.mountpoint.as_str()).collect();
if mountpoints.len() != live_parts.len() { 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 { for p in &live_parts {
if p.size_mb == 0 && p.mountpoint.is_empty() { if p.mountpoint.is_empty() && p.fstype != FsType::Lvm {
return Some(format!( return Some(t!("mp_error_empty_mountpoint").to_string());
"{} boyut 0 (kalan alan) belirtilmiş ama mountpoint boş", p.device
));
} }
} }
@@ -409,18 +465,43 @@ fn used_mb_on_disk(parts: &[CustomPartition], disk: &str, exclude_idx: Option<us
/// Sabit boyutlu tanımların disk boyutunu aşıp aşmadığını kontrol eder. /// Sabit boyutlu tanımların disk boyutunu aşıp aşmadığını kontrol eder.
/// "Kalan alan" (size 0) tanımları esnektir; katkı vermez. /// "Kalan alan" (size 0) tanımları esnektir; katkı vermez.
///
/// DÖNGÜ TARANIĞINDEKİ `disks` LİSTESİ ÜZERİNDEN DEĞİL, BÖLÜM
/// TANIMLARININ KENDİ `disk` ALANINDAN YÜRÜTÜLMEKTEDİR. Önceden
/// `disks` üzerinde yürütülüyordu; `available_disks` listesinde olmayan
/// bir diske yapılan tanım (küçük disk, mount ile eklenmiş disk vb.)
/// hiç kontrol edilmeden geçiyordu — kullanıcı kapasiteyi aşan bir
/// planı "geçerli" görüp kuruluma başlıyordu.
fn manual_capacity_error(parts: &[CustomPartition], disks: &[DiskInfo]) -> Option<String> { fn manual_capacity_error(parts: &[CustomPartition], disks: &[DiskInfo]) -> Option<String> {
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() let defs: Vec<&CustomPartition> = parts.iter()
.filter(|p| p.disk == d.name && !p.delete) .filter(|p| p.disk == d && !p.delete)
.collect(); .collect();
if defs.is_empty() { continue; } 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(); let fixed_mb: u64 = defs.iter().map(|p| p.size_mb).sum();
if fixed_mb > cap_mb { if fixed_mb > cap_mb {
return Some(format!( return Some(format!(
"{}: sabit tanımlar ({:.1} GB) disk boyutunu ({:.1} GB) aşıyor", "{}: 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 { } else {
!state.custom_partitions.is_empty() !state.custom_partitions.is_empty()
&& state.selected_disk.is_some() && 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() && 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ı). // konumunda TEK bir "Boş Alan" satırında birleşirler (GParted tarzı).
enum TableRow<'a> { enum TableRow<'a> {
Part(usize, &'a CustomPartition), 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<usize> },
} }
let mut visible_rows: Vec<TableRow> = Vec::new(); let mut visible_rows: Vec<TableRow> = Vec::new();
{ {
let mut seen_disks: Vec<&str> = Vec::new(); // Hangi diskler gösterilecek?
for part in &state.custom_partitions { //
if !seen_disks.contains(&part.disk.as_str()) { // ÖNEMLİ: yalnızca `custom_partitions` içinde geçen diskler
seen_disks.push(&part.disk); // 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<String> = 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). // Boş alan = disk boyutu − (korunan fiziksel + YENİ tanımlar).
// Silinenler zaten boşalır; yeni tanımlar da bu boşluktan // Silinenler zaten boşalır; yeni tanımlar da bu boşluktan
// yerleşeceği için gösterimden düşülürler. // yerleşeceği için gösterimden düşülürler.
@@ -922,7 +1030,7 @@ impl PartitionStep {
if let Some(first) = fs_first.take() { if let Some(first) = fs_first.take() {
if free_total_mb > 0 { if free_total_mb > 0 {
visible_rows.push(TableRow::FreeSpace { 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 let Some(first) = fs_first.take() {
if free_total_mb > 0 { if free_total_mb > 0 {
visible_rows.push(TableRow::FreeSpace { 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 total_rows = visible_rows.len();
let mut last_disk_header = String::new(); let mut last_disk_header = String::new();
for (vis_idx, row) in visible_rows.iter().enumerate() { for (vis_idx, row) in visible_rows.iter().enumerate() {
let (disk_name, global_idx, part): (&str, usize, Option<&CustomPartition>) = match row { let (disk_name, global_idx, part): (&str, Option<usize>, Option<&CustomPartition>) = match row {
TableRow::Part(gi, p) => (p.disk.as_str(), *gi, Some(p)), TableRow::Part(gi, p) => (p.disk.as_str(), Some(*gi), Some(p)),
TableRow::FreeSpace { disk, first_idx, .. } => (disk.as_str(), *first_idx, None), TableRow::FreeSpace { disk, first_idx, .. } => (disk.as_str(), *first_idx, None),
}; };
// Disk grubu başlığı: yeni diske geçildiğinde bir kez çiz // 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 is_free_row = part.is_none();
let row_bg = if is_free_row { let row_bg = if is_free_row {
egui::Color32::from_rgba_unmultiplied(180, 50, 40, 70) egui::Color32::from_rgba_unmultiplied(180, 50, 40, 70)
@@ -1054,8 +1173,17 @@ impl PartitionStep {
egui::Sense::click(), 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() { 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); row_response.on_hover_cursor(egui::CursorIcon::PointingHand);
@@ -1219,20 +1347,40 @@ impl PartitionStep {
self.manual.editing = false; self.manual.editing = false;
self.manual.create_over = false; self.manual.create_over = false;
self.manual.edit_idx = None; 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 active_disk = state.selected_disk.clone().unwrap_or_default();
let next_num = state.custom_partitions.iter() self.manual.add_disk = active_disk.clone();
.filter(|p| p.disk == active_disk) // Öneriler bu diskin DURUMUNA göre yapılır:
.count() + 1; // - 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_mountpoint.clear();
self.manual.add_encrypt = false; self.manual.add_encrypt = false;
self.manual.add_luks_password.clear(); self.manual.add_luks_password.clear();
self.manual.add_mountpoint = match next_num { self.manual.add_mountpoint = if self.is_uefi && !has_efi {
1 if self.is_uefi => "/boot/efi".to_string(), "/boot/efi".to_string()
1 => "/".to_string(), } else if !has_root {
2 => "swap".to_string(), "/".to_string()
3 => "/".to_string(), } else if !has_swap {
_ => String::new(), "swap".to_string()
} else {
String::new()
}; };
// Mountpoint'e göre otomatik boyut ata // 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 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<String> = 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<String> = 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",
&current,
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 // Slider — kullanılabilir alan kadar
// Hedef disk: düzenlemede bölümün kendi diski, // Hedef disk: yukarıda seçilen disk (düzenlemede
// eklemede aktif (seçili) disk. Boyut hesapları // varsayılan olarak bölümün kendi diski). Boyut
// yalnızca bu disk üzerinden yapılır. // hesapları YALNIZCA bu disk üzerinden yapılır.
let dialog_disk = if is_edit { let dialog_disk = if is_edit && self.manual.add_disk.is_empty() {
self.manual.edit_idx self.manual.edit_idx
.and_then(|i| state.custom_partitions.get(i)) .and_then(|i| state.custom_partitions.get(i))
.map(|p| p.disk.clone()) .map(|p| p.disk.clone())
.unwrap_or_default() .unwrap_or_default()
} else { } else if self.manual.add_disk.is_empty() {
state.selected_disk.clone().unwrap_or_default() state.selected_disk.clone().unwrap_or_default()
} else {
self.manual.add_disk.clone()
}; };
let disk_total_mb = disk_size_mb(state, &dialog_disk); 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); 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| { ui.horizontal(|ui| {
let ok_label = if is_create_like { t!("mp_add_btn") } else { t!("mp_save") }; let ok_label = if is_create_like { t!("mp_add_btn") } else { t!("mp_save") };
if theme::primary_button(ui, &ok_label).clicked() { if theme::primary_button(ui, &ok_label).clicked() {
// Hedef disk: düzenlemede bölümün diski, eklemede aktif disk // Hedef disk: diyalogdaki disk seçiciden gelir.
let target_disk = if is_edit { // Boşsa geriye uyum için aktif diske düşülür.
self.manual.edit_idx let target_disk = if !self.manual.add_disk.is_empty() {
.and_then(|i| state.custom_partitions.get(i)) self.manual.add_disk.clone()
.map(|p| p.disk.clone())
.unwrap_or_else(|| disk.clone())
} else { } else {
disk.clone() disk.clone()
}; };
@@ -1581,7 +1821,11 @@ impl PartitionStep {
let encrypt = self.manual.add_encrypt; let encrypt = self.manual.add_encrypt;
let luks_password = if encrypt { self.manual.add_luks_password.clone() } else { String::new() }; 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()); self.manual.error = Some(t!("mp_error_empty_mountpoint").to_string());
} else if target_disk.is_empty() { } else if target_disk.is_empty() {
self.manual.error = Some(t!("mp_select_disk_first").to_string()); 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 let Some(idx) = self.manual.edit_idx {
if idx < state.custom_partitions.len() { if idx < state.custom_partitions.len() {
let skip_format = self.manual.add_skip_format; 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 { state.custom_partitions[idx] = CustomPartition {
disk: state.custom_partitions[idx].disk.clone(), disk: target_disk.clone(),
device: state.custom_partitions[idx].device.clone(), // disk değiştiyse cihaz yolu
orig_device: state.custom_partitions[idx].orig_device.clone(), // yeniden atanmalı
device: if disk_moved {
String::new()
} else {
state.custom_partitions[idx].device.clone()
},
orig_device,
size_mb, size_mb,
fstype, fstype,
mountpoint: mp, mountpoint: mp,
@@ -1883,7 +2145,12 @@ impl PartitionStep {
egui::Color32::from_rgb(217, 0, 91), egui::Color32::from_rgb(217, 0, 91),
format!("\u{ECA1} {}", warn), 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( ui.colored_label(
egui::Color32::from_rgb(220, 160, 40), egui::Color32::from_rgb(220, 160, 40),
format!("\u{ECA1} {}", warn), format!("\u{ECA1} {}", warn),
@@ -2170,23 +2437,29 @@ fn format_number_thousands(n: u64) -> String {
result 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 { DiskInfo {
name: name.to_string(), name: name.to_string(),
vendor: "ATA".to_string(), vendor: String::new(),
model: "VBOX HARDDISK".to_string(), model: "TEST".to_string(),
drive_type: "HDD".to_string(), drive_type: String::new(),
size_gb, size_gb,
size_bytes: size_gb * 1_073_741_824, 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 { CustomPartition {
disk: crate::installer::device_to_disk(device), disk: crate::installer::device_to_disk(device),
device: device.to_string(), device: device.to_string(),
@@ -2201,6 +2474,15 @@ mod tests {
delete: false, delete: false,
} }
} }
}
#[cfg(test)]
mod tests {
use super::*;
use super::testutil::{disk, part};
use crate::installer::{LogicalVolume, PartitionTableType};
fn step(uefi: bool) -> PartitionStep { fn step(uefi: bool) -> PartitionStep {
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");
}
}