🔒 Repository is read-only – file editing is disabled.

luna/apps/luna-files/src/trash.rs main

415 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
//! Kosz zgodny ze specyfikacją XDG Trash (`.trashinfo`).
//!
//! MVP używa kosza domowego (`$XDG_DATA_HOME/Trash`). Dla plików z innego systemu
//! plików `rename` zwróci `EXDEV` — wtedy kopiujemy i usuwamy oryginał (specyfikacja
//! dopuszcza też kosz na szczycie montowania; to kolejny krok).

use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use crate::model::{now_secs, trash_date};

pub fn home() -> PathBuf {
    env::var_os("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("/"))
}

/// Katalog kosza użytkownika.
pub fn trash_root() -> PathBuf {
    env::var_os("XDG_DATA_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| home().join(".local").join("share"))
        .join("Trash")
}

/// Przenosi plik/katalog do kosza; zwraca ścieżkę w `Trash/files`.
pub fn move_to_trash(path: &Path) -> io::Result<PathBuf> {
    move_to_trash_in(path, &trash_root())
}

/// Jak [`move_to_trash`], ale z jawnym katalogiem kosza (testy, inne montowania).
pub fn move_to_trash_in(path: &Path, root: &Path) -> io::Result<PathBuf> {
    let files = root.join("files");
    let info = root.join("info");
    fs::create_dir_all(&files)?;
    fs::create_dir_all(&info)?;

    let name = path
        .file_name()
        .and_then(|n| n.to_str())
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "zła nazwa pliku"))?;
    let original = absolute(path);

    // Unikalna nazwa w koszu: `raport.pdf`, `raport.2.pdf`, …
    let (stem, ext) = split_name(name);
    let mut candidate = name.to_owned();
    let mut n = 1u32;
    while files.join(&candidate).symlink_metadata().is_ok()
        || info.join(format!("{candidate}.trashinfo")).exists()
    {
        n += 1;
        candidate = if ext.is_empty() {
            format!("{stem}.{n}")
        } else {
            format!("{stem}.{n}.{ext}")
        };
    }

    let dest = files.join(&candidate);
    move_path(path, &dest)?;

    let mut f = fs::File::create(info.join(format!("{candidate}.trashinfo")))?;
    writeln!(f, "[Trash Info]")?;
    writeln!(f, "Path={}", encode_path(&original))?;
    writeln!(f, "DeletionDate={}", trash_date(now_secs()))?;
    Ok(dest)
}

/// Wpis w koszu (z `.trashinfo`).
#[derive(Debug, Clone)]
pub struct Trashed {
    pub name: String,
    /// Ścieżka w `Trash/files`.
    pub path: PathBuf,
    /// Oryginalna lokalizacja.
    pub original: PathBuf,
    pub deleted: String,
}

/// Zawartość kosza (bez sortowania — robi to UI).
pub fn list() -> Vec<Trashed> {
    list_in(&trash_root())
}

pub fn list_in(root: &Path) -> Vec<Trashed> {
    let mut out = Vec::new();
    let info = root.join("info");
    let Ok(entries) = fs::read_dir(&info) else {
        return out;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("trashinfo") {
            continue;
        }
        let Some(name) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
            continue;
        };
        let Ok(text) = fs::read_to_string(&path) else {
            continue;
        };
        let mut original = PathBuf::new();
        let mut deleted = String::new();
        for line in text.lines() {
            if let Some(v) = line.strip_prefix("Path=") {
                original = PathBuf::from(decode_path(v));
            } else if let Some(v) = line.strip_prefix("DeletionDate=") {
                deleted = v.to_owned();
            }
        }
        out.push(Trashed {
            path: root.join("files").join(&name),
            name,
            original,
            deleted,
        });
    }
    out
}

/// Opróżnia kosz; zwraca `(liczba plików, zwolnione bajty)`.
pub fn empty_trash() -> io::Result<(usize, u64)> {
    empty_trash_in(&trash_root())
}

pub fn empty_trash_in(root: &Path) -> io::Result<(usize, u64)> {
    let files = root.join("files");
    let info = root.join("info");
    let mut count = 0usize;
    let mut bytes = 0u64;
    if let Ok(entries) = fs::read_dir(&files) {
        for entry in entries.flatten() {
            let path = entry.path();
            bytes += path_size(&path);
            remove_path(&path)?;
            count += 1;
        }
    }
    if let Ok(entries) = fs::read_dir(&info) {
        for entry in entries.flatten() {
            let _ = fs::remove_file(entry.path());
        }
    }
    Ok((count, bytes))
}

/// Ścieżka `.trashinfo` odpowiadająca plikowi w `Trash/files`.
pub fn info_for(trashed: &Path) -> Option<PathBuf> {
    let name = trashed.file_name()?.to_string_lossy();
    let root = trashed.parent()?.parent()?;
    Some(root.join("info").join(format!("{name}.trashinfo")))
}

/// Przywraca plik z kosza na jego pierwotną ścieżkę (z unikalną nazwą,
/// jeśli coś już tam jest). Usuwa wpis `.trashinfo`.
pub fn restore(trashed: &Path) -> io::Result<PathBuf> {
    let info = info_for(trashed).ok_or_else(|| {
        io::Error::new(io::ErrorKind::InvalidInput, "nieprawidłowa ścieżka kosza")
    })?;
    let text = fs::read_to_string(&info)?;
    let original = text
        .lines()
        .find_map(|line| line.strip_prefix("Path="))
        .map(|v| PathBuf::from(decode_path(v)))
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "brak Path w .trashinfo"))?;
    if let Some(parent) = original.parent() {
        fs::create_dir_all(parent)?;
    }
    let dest = if original.symlink_metadata().is_ok() {
        let dir = original.parent().unwrap_or_else(|| Path::new("/"));
        let name = original
            .file_name()
            .map(|n| n.to_os_string())
            .unwrap_or_else(|| std::ffi::OsString::from("plik"));
        let name_str = name.to_string_lossy().into_owned();
        let (stem, ext) = split_name(&name_str);
        let mut n = 1u32;
        loop {
            let candidate = if ext.is_empty() {
                dir.join(format!("{stem} ({n})"))
            } else {
                dir.join(format!("{stem} ({n}).{ext}"))
            };
            if candidate.symlink_metadata().is_err() {
                break candidate;
            }
            n += 1;
        }
    } else {
        original
    };
    move_path(trashed, &dest)?;
    let _ = fs::remove_file(&info);
    Ok(dest)
}

/// Usuwa wpis z kosza trwale (plik + `.trashinfo`).
pub fn purge(trashed: &Path) -> io::Result<()> {
    remove_path(trashed)?;
    if let Some(info) = info_for(trashed) {
        let _ = fs::remove_file(info);
    }
    Ok(())
}

fn split_name(name: &str) -> (&str, &str) {
    match name.rsplit_once('.') {
        Some((stem, ext)) if !stem.is_empty() => (stem, ext),
        _ => (name, ""),
    }
}

fn absolute(path: &Path) -> PathBuf {
    if path.is_absolute() {
        return path.to_path_buf();
    }
    env::current_dir()
        .map(|cwd| cwd.join(path))
        .unwrap_or_else(|_| path.to_path_buf())
}

fn move_path(src: &Path, dst: &Path) -> io::Result<()> {
    match fs::rename(src, dst) {
        Ok(()) => Ok(()),
        // Inny system plików (pendrive, inna partycja): kopiujemy i usuwamy oryginał.
        Err(e) if e.raw_os_error() == Some(libc::EXDEV) => {
            copy_path(src, dst)?;
            remove_path(src)
        }
        Err(e) => Err(e),
    }
}

fn copy_path(src: &Path, dst: &Path) -> io::Result<()> {
    let meta = fs::symlink_metadata(src)?;
    if meta.is_dir() {
        fs::create_dir_all(dst)?;
        for entry in fs::read_dir(src)? {
            let entry = entry?;
            copy_path(&entry.path(), &dst.join(entry.file_name()))?;
        }
        Ok(())
    } else if meta.file_type().is_symlink() {
        let target = fs::read_link(src)?;
        std::os::unix::fs::symlink(target, dst)
    } else {
        fs::copy(src, dst).map(|_| ())
    }
}

fn remove_path(path: &Path) -> io::Result<()> {
    let meta = fs::symlink_metadata(path)?;
    if meta.is_dir() {
        fs::remove_dir_all(path)
    } else {
        fs::remove_file(path)
    }
}

fn path_size(path: &Path) -> u64 {
    let Ok(meta) = fs::symlink_metadata(path) else {
        return 0;
    };
    if meta.is_dir() {
        let mut sum = 0;
        if let Ok(entries) = fs::read_dir(path) {
            for entry in entries.flatten() {
                sum += path_size(&entry.path());
            }
        }
        sum
    } else {
        meta.len()
    }
}

/// Procentowe kodowanie ścieżki zgodnie z XDG Trash Spec (bez kodowania `/`).
pub fn encode_path(path: &Path) -> String {
    let mut out = String::new();
    for &b in path.as_os_str().as_encoded_bytes() {
        match b {
            b'/' | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(b as char)
            }
            _ => out.push_str(&format!("%{b:02X}")),
        }
    }
    out
}

fn decode_path(text: &str) -> String {
    let bytes = text.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            if let Ok(v) = u8::from_str_radix(&text[i + 1..i + 3], 16) {
                out.push(v);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn temp_dir(tag: &str) -> PathBuf {
        let dir = env::temp_dir().join(format!(
            "luna-files-trash-{tag}-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn trashing_keeps_content_and_writes_info() {
        let base = temp_dir("basic");
        let datadir = base.join("Trash");
        let file = base.join("notatka.txt");
        fs::write(&file, "treść").unwrap();

        let dest = move_to_trash_in(&file, &datadir).unwrap();
        assert!(!file.exists());
        assert_eq!(fs::read_to_string(&dest).unwrap(), "treść");

        let info = fs::read_to_string(datadir.join("info/notatka.txt.trashinfo")).unwrap();
        assert!(info.starts_with("[Trash Info]\n"));
        assert!(info.contains("Path="));
        assert!(info.contains("DeletionDate="));
        assert!(!info.contains("Path=%2F")); // slashe nie są kodowane
    }

    #[test]
    fn name_collisions_get_a_suffix() {
        let base = temp_dir("collision");
        let root = base.join("Trash");
        let a = base.join("dokument.txt");
        let b = base.join("dokument.txt");
        fs::write(&a, "a").unwrap();
        move_to_trash_in(&a, &root).unwrap();
        fs::write(&b, "b").unwrap();
        let dest = move_to_trash_in(&b, &root).unwrap();
        assert_eq!(dest.file_name().unwrap(), "dokument.2.txt");
        assert!(root.join("info/dokument.2.txt.trashinfo").exists());
    }

    #[test]
    fn listing_reads_original_path() {
        let base = temp_dir("list");
        let root = base.join("Trash");
        let file = base.join("plik.bin");
        fs::write(&file, [1u8, 2, 3]).unwrap();
        move_to_trash_in(&file, &root).unwrap();
        let items = list_in(&root);
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].name, "plik.bin");
        assert_eq!(items[0].original, file);
    }

    #[test]
    fn emptying_clears_files_and_info() {
        let base = temp_dir("empty");
        let root = base.join("Trash");
        let a = base.join("a.txt");
        fs::write(&a, "12345").unwrap();
        move_to_trash_in(&a, &root).unwrap();
        let (count, bytes) = empty_trash_in(&root).unwrap();
        assert_eq!(count, 1);
        assert_eq!(bytes, 5);
        assert!(list_in(&root).is_empty());
    }

    #[test]
    fn restore_puts_file_back() {
        let base = temp_dir("restore");
        let root = base.join("Trash");
        let original = base.join("dane.txt");
        fs::write(&original, "abc").unwrap();
        move_to_trash_in(&original, &root).unwrap();
        assert!(!original.exists());
        let restored = restore(&root.join("files/dane.txt")).unwrap();
        assert_eq!(restored, original);
        assert_eq!(fs::read_to_string(&restored).unwrap(), "abc");
        assert!(!root.join("info/dane.txt.trashinfo").exists());
    }

    #[test]
    fn purge_removes_file_and_info() {
        let base = temp_dir("purge");
        let root = base.join("Trash");
        let file = base.join("x.bin");
        fs::write(&file, [0u8; 4]).unwrap();
        let dest = move_to_trash_in(&file, &root).unwrap();
        purge(&dest).unwrap();
        assert!(!dest.exists());
        assert!(!root.join("info/x.bin.trashinfo").exists());
    }

    #[test]
    fn path_encoding_escapes_spaces() {
        assert_eq!(encode_path(Path::new("/a b/c.txt")), "/a%20b/c.txt");
        assert_eq!(decode_path("/a%20b/c.txt"), "/a b/c.txt");
    }
}