Files
driftwood/docs/plans/2026-02-27-audit-fixes-implementation.md
lashman e9343da249 Fix 29 audit findings across all severity tiers
Critical: fix unsquashfs arg order, quote Exec paths with spaces,
fix compare_versions antisymmetry, chunk-based signature detection,
bounded ELF header reads.

High: handle NULL CVE severity, prevent pipe deadlock in inspector,
fix glob_match edge case, fix backup archive path collisions, async
crash detection with stderr capture.

Medium: gate scan on auto-scan setting, fix window size persistence,
fix announce() for Stack containers, claim lightbox gesture, use
serde_json for CLI output, remove dead CSS @media blocks, add
detail-tab persistence, remove invalid metainfo categories, byte-level
fuse signature search.

Low: tighten Wayland env var detection, ELF magic validation,
timeout for update info extraction, quoted arg parsing, stop watcher
timer on window destroy, GSettings choices/range constraints, remove
unused CSS classes, define status-ok/status-attention CSS.
2026-02-27 22:08:53 +02:00

31 KiB

Audit Fixes Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Fix all 29 findings from the full codebase correctness audit, organized by severity tier.

Architecture: Fix by severity tier (Critical -> High -> Medium -> Low) with a cargo build gate after each tier. Each task is a single focused fix. Fixes #10 and #11 (launcher async + stderr) are combined because they touch the same function.

Tech Stack: Rust, gtk4-rs, libadwaita-rs, rusqlite, serde_json, std::io


Tier 1: Critical Fixes

Task 1: Fix unsquashfs argument order in security.rs

Files:

  • Modify: src/core/security.rs:253-261

Step 1: Fix the argument order

The unsquashfs command expects: unsquashfs [options] <filesystem> [patterns]. Currently the AppImage path comes after the pattern. Fix:

    let extract_output = Command::new("unsquashfs")
        .args(["-o", &offset, "-f", "-d"])
        .arg(temp_dir.path())
        .arg("-no-progress")
        .arg(appimage_path)
        .arg(lib_file_path.trim_start_matches("squashfs-root/"))
        .output()
        .ok()?;

Remove the -e flag (unsquashfs takes extract patterns as positional args after the archive path, not with -e).

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 2: Quote Exec path in desktop entries

Files:

  • Modify: src/core/integrator.rs:97

Step 1: Add quotes around the exec path

Change line 97 from:

         Exec={exec} %U\n\

to:

         Exec=\"{exec}\" %U\n\

This ensures paths with spaces (e.g., /home/user/My Apps/Firefox.AppImage) work correctly per the Desktop Entry Specification.

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 3: Fix compare_versions total order violation

Files:

  • Modify: src/core/duplicates.rs:332-341

Step 1: Use clean_version for equality check

Replace the function body:

fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
    use super::updater::version_is_newer;
    use super::updater::clean_version;

    let ca = clean_version(a);
    let cb = clean_version(b);

    if ca == cb {
        std::cmp::Ordering::Equal
    } else if version_is_newer(a, b) {
        std::cmp::Ordering::Greater
    } else {
        std::cmp::Ordering::Less
    }
}

Note: clean_version is currently fn (private). It must be made pub(crate) in src/core/updater.rs:704.

Step 2: Make clean_version pub(crate)

In src/core/updater.rs:704, change fn clean_version to pub(crate) fn clean_version.

Step 3: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 4: Chunk-based signature detection in inspector.rs

Files:

  • Modify: src/core/inspector.rs:530-537

Step 1: Replace fs::read with chunked BufReader

Replace the detect_signature function:

fn detect_signature(path: &Path) -> bool {
    use std::io::{BufReader, Read};
    let file = match fs::File::open(path) {
        Ok(f) => f,
        Err(_) => return false,
    };
    let needle = b".sha256_sig";
    let mut reader = BufReader::new(file);
    let mut buf = vec![0u8; 64 * 1024];
    let mut carry = Vec::new();

    loop {
        let n = match reader.read(&mut buf) {
            Ok(0) => break,
            Ok(n) => n,
            Err(_) => break,
        };
        // Prepend carry bytes from previous chunk to handle needle spanning chunks
        let search_buf = if carry.is_empty() {
            &buf[..n]
        } else {
            carry.extend_from_slice(&buf[..n]);
            carry.as_slice()
        };
        if search_buf.windows(needle.len()).any(|w| w == needle) {
            return true;
        }
        // Keep the last (needle.len - 1) bytes as carry for the next iteration
        let keep = needle.len() - 1;
        carry.clear();
        if n >= keep {
            carry.extend_from_slice(&buf[n - keep..n]);
        } else {
            carry.extend_from_slice(&buf[..n]);
        }
    }
    false
}

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 5: Read only first 12 bytes in verify_appimage

Files:

  • Modify: src/core/updater.rs:961-980

Step 1: Replace fs::read with targeted read

fn verify_appimage(path: &Path) -> bool {
    use std::io::Read;
    let mut file = match fs::File::open(path) {
        Ok(f) => f,
        Err(_) => return false,
    };
    let mut header = [0u8; 12];
    if file.read_exact(&mut header).is_err() {
        return false;
    }
    // Check ELF magic
    if &header[0..4] != b"\x7FELF" {
        return false;
    }
    // Check AppImage Type 2 magic at offset 8: AI\x02
    if header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x02 {
        return true;
    }
    // Check AppImage Type 1 magic at offset 8: AI\x01
    header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x01
}

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 6: Tier 1 build gate

Run: cargo build 2>&1 | grep "warning\|error" Expected: Zero output (clean build with no warnings or errors)


Tier 2: High Fixes

Task 7: Handle NULL severity in CVE summaries

Files:

  • Modify: src/core/database.rs:1348-1349 and 1369-1370

Step 1: Change get_cve_summary to handle Option

At line 1348-1349, change:

            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))

to:

            let severity: String = row.get::<_, Option<String>>(0)?
                .unwrap_or_else(|| "MEDIUM".to_string());
            Ok((severity, row.get::<_, i64>(1)?))

Step 2: Same fix for get_all_cve_summary

At line 1369-1370, apply the same change:

            let severity: String = row.get::<_, Option<String>>(0)?
                .unwrap_or_else(|| "MEDIUM".to_string());
            Ok((severity, row.get::<_, i64>(1)?))

Step 3: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 8: Fix potential deadlock in extract_metadata_files

Files:

  • Modify: src/core/inspector.rs:248

Step 1: Change piped stderr to null

At line 248, change:

        .stderr(std::process::Stdio::piped())

to:

        .stderr(std::process::Stdio::null())

We don't use the stderr output (we only check the exit status), so piping it creates a deadlock risk for no benefit.

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 9: Fix glob_match edge case

Files:

  • Modify: src/core/updater.rs:680-698

Step 1: Reduce search text after matching the last part

Replace the function body from the // Last part must match section (line 680) through the return (line 700):

    // Last part must match at the end (unless pattern ends with *)
    let last = parts[parts.len() - 1];
    let end_limit = if !last.is_empty() {
        if !text.ends_with(last) {
            return false;
        }
        text.len() - last.len()
    } else {
        text.len()
    };

    // Middle parts must appear in order within the allowed range
    for part in &parts[1..parts.len() - 1] {
        if part.is_empty() {
            continue;
        }
        if pos >= end_limit {
            return false;
        }
        if let Some(found) = text[pos..end_limit].find(part) {
            pos += found + part.len();
        } else {
            return false;
        }
    }

    true
}

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 10: Fix backup archive filename collisions

Files:

  • Modify: src/core/backup.rs:122-132 and 195-198

Step 1: Use home-relative paths in archive creation

Replace the tar archive entry loop (lines 122-132):

    let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));

    for entry in &entries {
        let source = Path::new(&entry.original_path);
        if source.exists() {
            if let Ok(rel) = source.strip_prefix(&home_dir) {
                tar_args.push("-C".to_string());
                tar_args.push(home_dir.to_string_lossy().to_string());
                tar_args.push(rel.to_string_lossy().to_string());
            } else {
                // Path outside home dir - use parent/filename as fallback
                tar_args.push("-C".to_string());
                tar_args.push(
                    source.parent().unwrap_or(Path::new("/")).to_string_lossy().to_string(),
                );
                tar_args.push(
                    source.file_name().unwrap_or_default().to_string_lossy().to_string(),
                );
            }
        }
    }

Note: Need use std::path::PathBuf; if not already imported.

Step 2: Fix restore to match the new archive layout

Replace the restore path lookup (lines 195-198):

    for entry in &manifest.paths {
        let source = Path::new(&entry.original_path);
        let extracted = if let Ok(rel) = source.strip_prefix(&home_dir) {
            temp_dir.path().join(rel)
        } else {
            let source_name = source.file_name().unwrap_or_default();
            temp_dir.path().join(source_name)
        };
        let target = Path::new(&entry.original_path);

Add let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); before the restore loop.

Step 3: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 11: Async crash detection + drop stderr pipe

Files:

  • Modify: src/core/launcher.rs:147-224

Step 1: Remove blocking sleep, return immediately, drop stderr on success

Replace the entire execute_appimage function (lines 147-224):

/// Execute the AppImage process with the given method.
fn execute_appimage(
    appimage_path: &Path,
    method: &LaunchMethod,
    args: &[String],
    extra_env: &[(&str, &str)],
) -> LaunchResult {
    let mut cmd = match method {
        LaunchMethod::Direct => {
            let mut c = Command::new(appimage_path);
            c.args(args);
            c
        }
        LaunchMethod::ExtractAndRun => {
            let mut c = Command::new(appimage_path);
            c.env("APPIMAGE_EXTRACT_AND_RUN", "1");
            c.args(args);
            c
        }
        LaunchMethod::Sandboxed => {
            let mut c = Command::new("firejail");
            c.arg("--appimage");
            c.arg(appimage_path);
            c.args(args);
            c
        }
    };

    // Apply extra environment variables
    for (key, value) in extra_env {
        cmd.env(key, value);
    }

    // Detach stdin, let stderr go to /dev/null to prevent pipe buffer deadlock
    cmd.stdin(Stdio::null());
    cmd.stderr(Stdio::null());

    match cmd.spawn() {
        Ok(child) => {
            LaunchResult::Started {
                child,
                method: method.clone(),
            }
        }
        Err(e) => LaunchResult::Failed(e.to_string()),
    }
}

The caller side (detail_view.rs and window.rs) already has async crash detection via glib::timeout_future + child.try_wait(). The 1.5s sleep was redundant with that pattern. Since we now use Stdio::null() for stderr, we need to update the Crashed variant handling in callers to not expect stderr content from the launcher - callers can capture stderr separately if needed.

Actually, let me check if callers rely on the Crashed variant. Looking at the current flow: execute_appimage returns Started or Failed. The Crashed variant was produced by the sleep+try_wait logic we're removing. The callers in detail_view.rs (line 131) and window.rs pattern-match on Crashed. We need to remove those match arms since execute_appimage will never produce Crashed anymore.

Step 2: Update detail_view.rs caller

At line 131 in detail_view.rs, the match arm Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) should be removed. The caller already detects crashes async via the wayland analysis timeout (lines 119-129). However, crash detection UX needs to be preserved. Let's keep the Crashed variant in the enum but not produce it from execute_appimage. Instead, add a comment that callers should poll child.try_wait() after a delay to detect crashes. This is already the pattern used in window.rs.

Actually, looking more carefully at the callers: the detail_view.rs code at line 112-146 handles Started, Crashed, and Failed. If we remove Crashed production from execute_appimage, immediate crashes (process exits within milliseconds of spawning) would show as Started and the user would see "Launched" with no feedback that it crashed.

Better approach: keep crash detection but make it non-blocking. Change execute_appimage to not sleep. Instead, do a single non-blocking try_wait() with no sleep. This catches processes that fail immediately (e.g., missing executable, permission denied) without blocking for 1.5 seconds:

    match cmd.spawn() {
        Ok(mut child) => {
            // Non-blocking check: catch immediate spawn failures
            // (e.g., missing libs, exec format error)
            match child.try_wait() {
                Ok(Some(status)) => {
                    // Already exited - immediate crash
                    LaunchResult::Crashed {
                        exit_code: status.code(),
                        stderr: String::new(),
                        method: method.clone(),
                    }
                }
                _ => {
                    // Still running or can't check - assume success
                    LaunchResult::Started {
                        child,
                        method: method.clone(),
                    }
                }
            }
        }
        Err(e) => LaunchResult::Failed(e.to_string()),
    }

This gives us: no blocking sleep, no stderr pipe deadlock, still catches instant crashes. The stderr field is empty (since we use Stdio::null) but that's fine - the crash dialog still shows the exit code.

Step 3: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 12: Tier 2 build gate

Run: cargo build 2>&1 | grep "warning\|error" Expected: Zero output


Tier 3: Medium Fixes

Task 13: Gate scan on auto-scan-on-startup setting

Files:

  • Modify: src/window.rs:834-835

Step 1: Wrap trigger_scan in settings check

Replace lines 834-835:

        // Always scan on startup to discover new AppImages and complete pending analyses
        self.trigger_scan();

with:

        // Scan on startup if enabled in preferences
        if self.settings().boolean("auto-scan-on-startup") {
            self.trigger_scan();
        }

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 14: Fix window size persistence

Files:

  • Modify: src/window.rs:1252

Step 1: Use actual dimensions instead of default_size

Change line 1252 from:

        let (width, height) = self.default_size();

to:

        let (width, height) = (self.width(), self.height());

self.width() and self.height() return the current allocated size, which is what we want to persist. default_size() returns the value set by set_default_size(), which doesn't change on manual resize.

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 15: Fix announce() to work with any container

Files:

  • Modify: src/ui/widgets.rs:347-363

Step 1: Replace Box-specific logic with generic parent approach

Replace the announce function:

pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
    let label = gtk::Label::builder()
        .label(text)
        .visible(false)
        .accessible_role(gtk::AccessibleRole::Alert)
        .build();
    label.update_property(&[gtk::accessible::Property::Label(text)]);

    // Try common container types
    if let Some(box_widget) = container.dynamic_cast_ref::<gtk::Box>() {
        box_widget.append(&label);
        label.set_visible(true);
        let label_clone = label.clone();
        let box_clone = box_widget.clone();
        glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || {
            box_clone.remove(&label_clone);
        });
    } else if let Some(stack) = container.dynamic_cast_ref::<gtk::Stack>() {
        // For stacks, add to the visible child if it's a box, else use overlay
        if let Some(child) = stack.visible_child() {
            if let Some(child_box) = child.dynamic_cast_ref::<gtk::Box>() {
                child_box.append(&label);
                label.set_visible(true);
                let label_clone = label.clone();
                let box_clone = child_box.clone();
                glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || {
                    box_clone.remove(&label_clone);
                });
            }
        }
    } else if let Some(overlay) = container.dynamic_cast_ref::<adw::ToastOverlay>() {
        if let Some(child) = overlay.child() {
            if let Some(child_box) = child.dynamic_cast_ref::<gtk::Box>() {
                child_box.append(&label);
                label.set_visible(true);
                let label_clone = label.clone();
                let box_clone = child_box.clone();
                glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || {
                    box_clone.remove(&label_clone);
                });
            }
        }
    }
}

Note: Needs use adw::prelude::*; if not already imported in widgets.rs.

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 16: Claim gesture in lightbox

Files:

  • Modify: src/ui/detail_view.rs:1948

Step 1: Claim the gesture sequence

Change line 1948 from:

    pic_gesture.connect_released(|_, _, _, _| {});

to:

    pic_gesture.connect_released(|gesture, _, _, _| {
        gesture.set_state(gtk::EventSequenceState::Claimed);
    });

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 17: Use serde_json for CLI JSON output

Files:

  • Modify: src/cli.rs:114-131

Step 1: Replace hand-crafted JSON with serde_json

Replace lines 114-131:

    if format == "json" {
        let items: Vec<serde_json::Value> = records
            .iter()
            .map(|r| {
                serde_json::json!({
                    "name": r.app_name.as_deref().unwrap_or(&r.filename),
                    "version": r.app_version.as_deref().unwrap_or(""),
                    "path": r.path,
                    "size": r.size_bytes,
                    "integrated": r.integrated,
                })
            })
            .collect();
        println!("{}", serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".into()));
        return ExitCode::SUCCESS;
    }

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 18: Remove dead @media blocks from CSS

Files:

  • Modify: data/resources/style.css:174-211

Step 1: Delete both @media blocks

Remove lines 174-211 (the @media (prefers-color-scheme: dark) and @media (prefers-contrast: more) blocks). GTK4 CSS does not support these media queries - they are silently ignored. The libadwaita named colors (@warning_bg_color, etc.) already adapt to dark mode automatically.

Keep the comment at line 213-216 about prefers-reduced-motion since it explains why GTK handles it differently.

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build (CSS is compiled at build time via gresource)


Task 19: Wire detail-tab persistence

Files:

  • Modify: src/ui/detail_view.rs:30-32 (read setting) and add save on tab switch

Step 1: Restore saved tab on detail view open

After the view_stack creation (line 32), after all tabs are added (around line 55), add:

    // Restore last selected tab
    let settings = gio::Settings::new(crate::config::APP_ID);
    let saved_tab = settings.string("detail-tab");
    if view_stack.child_by_name(&saved_tab).is_some() {
        view_stack.set_visible_child_name(&saved_tab);
    }

Step 2: Save tab on switch

After the tab restore code, add:

    // Save tab selection
    let settings_tab = settings.clone();
    view_stack.connect_visible_child_name_notify(move |stack| {
        if let Some(name) = stack.visible_child_name() {
            settings_tab.set_string("detail-tab", &name).ok();
        }
    });

Ensure use gtk::gio; and use crate::config::APP_ID; are imported at the top of detail_view.rs (check if they already are).

Step 3: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 20: Remove invalid categories from metainfo

Files:

  • Modify: data/app.driftwood.Driftwood.metainfo.xml:54-58

Step 1: Delete the categories block

Remove lines 54-58:

  <categories>
    <category>System</category>
    <category>PackageManager</category>
    <category>GTK</category>
  </categories>

Per AppStream spec, categories belong in the .desktop file only (where they already are).

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 21: Byte-level search in fuse.rs

Files:

  • Modify: src/core/fuse.rs:189-192

Step 1: Replace lossy UTF-8 conversion with byte-level search

Replace lines 188-192:

    let data = &buf[..n];
    let haystack = String::from_utf8_lossy(data).to_lowercase();
    haystack.contains("type2-runtime")
        || haystack.contains("libfuse3")

with:

    let data = &buf[..n];
    fn bytes_contains_ci(haystack: &[u8], needle: &[u8]) -> bool {
        haystack.windows(needle.len()).any(|w| {
            w.iter().zip(needle).all(|(a, b)| a.to_ascii_lowercase() == *b)
        })
    }
    bytes_contains_ci(data, b"type2-runtime")
        || bytes_contains_ci(data, b"libfuse3")

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 22: Tier 3 build gate

Run: cargo build 2>&1 | grep "warning\|error" Expected: Zero output


Tier 4: Low Fixes

Task 23: Tighten Wayland env var detection

Files:

  • Modify: src/core/wayland.rs:389-393

Step 1: Remove WAYLAND_DISPLAY from fallback

Change lines 389-393 from:

        has_wayland_socket = env_vars.iter().any(|(k, v)| {
            (k == "GDK_BACKEND" && v.contains("wayland"))
                || (k == "QT_QPA_PLATFORM" && v.contains("wayland"))
                || (k == "WAYLAND_DISPLAY" && !v.is_empty())
        });

to:

        has_wayland_socket = env_vars.iter().any(|(k, v)| {
            (k == "GDK_BACKEND" && v.contains("wayland"))
                || (k == "QT_QPA_PLATFORM" && v.contains("wayland"))
        });

WAYLAND_DISPLAY is typically inherited from the parent environment and doesn't indicate the app is actually using Wayland.

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 24: Add ELF magic validation to detect_architecture

Files:

  • Modify: src/core/inspector.rs:427-440

Step 1: Add magic check and endianness-aware parsing

Replace the function:

fn detect_architecture(path: &Path) -> Option<String> {
    let mut file = fs::File::open(path).ok()?;
    let mut header = [0u8; 20];
    file.read_exact(&mut header).ok()?;

    // Validate ELF magic
    if &header[0..4] != b"\x7FELF" {
        return None;
    }

    // ELF e_machine at offset 18, endianness from byte 5
    let machine = if header[5] == 2 {
        // Big-endian
        u16::from_be_bytes([header[18], header[19]])
    } else {
        // Little-endian (default)
        u16::from_le_bytes([header[18], header[19]])
    };

    match machine {
        0x03 => Some("i386".to_string()),
        0x3E => Some("x86_64".to_string()),
        0xB7 => Some("aarch64".to_string()),
        0x28 => Some("armhf".to_string()),
        _ => Some(format!("unknown(0x{:02X})", machine)),
    }
}

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 25: Add timeout to extract_update_info_runtime

Files:

  • Modify: src/core/updater.rs:372-388

Step 1: Add 5-second timeout

Replace the function:

fn extract_update_info_runtime(path: &Path) -> Option<String> {
    let mut child = std::process::Command::new(path)
        .arg("--appimage-updateinformation")
        .env("APPIMAGE_EXTRACT_AND_RUN", "1")
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .spawn()
        .ok()?;

    let timeout = std::time::Duration::from_secs(5);
    let start = std::time::Instant::now();
    loop {
        match child.try_wait() {
            Ok(Some(status)) => {
                if status.success() {
                    let mut output = String::new();
                    if let Some(mut stdout) = child.stdout.take() {
                        use std::io::Read;
                        stdout.read_to_string(&mut output).ok()?;
                    }
                    let info = output.trim().to_string();
                    if !info.is_empty() && info.contains('|') {
                        return Some(info);
                    }
                }
                return None;
            }
            Ok(None) => {
                if start.elapsed() >= timeout {
                    let _ = child.kill();
                    let _ = child.wait();
                    log::warn!("Timed out reading update info from {}", path.display());
                    return None;
                }
                std::thread::sleep(std::time::Duration::from_millis(50));
            }
            Err(_) => return None,
        }
    }
}

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 26: Handle quoted args in parse_launch_args

Files:

  • Modify: src/core/launcher.rs:227-232

Step 1: Implement simple shell-like tokenizer

Replace the function:

pub fn parse_launch_args(args: Option<&str>) -> Vec<String> {
    let Some(s) = args else {
        return Vec::new();
    };
    let s = s.trim();
    if s.is_empty() {
        return Vec::new();
    }

    let mut result = Vec::new();
    let mut current = String::new();
    let mut in_quotes = false;
    let mut chars = s.chars().peekable();

    while let Some(c) = chars.next() {
        match c {
            '"' => in_quotes = !in_quotes,
            ' ' | '\t' if !in_quotes => {
                if !current.is_empty() {
                    result.push(std::mem::take(&mut current));
                }
            }
            _ => current.push(c),
        }
    }
    if !current.is_empty() {
        result.push(current);
    }

    result
}

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 27: Stop watcher timer on window destroy

Files:

  • Modify: src/window.rs:1114-1121

Step 1: Return Break when window is gone

Replace lines 1114-1121:

            glib::timeout_add_local(std::time::Duration::from_secs(1), move || {
                if changed.swap(false, std::sync::atomic::Ordering::Relaxed) {
                    if let Some(window) = window_weak.upgrade() {
                        window.trigger_scan();
                    }
                }
                glib::ControlFlow::Continue
            });

with:

            glib::timeout_add_local(std::time::Duration::from_secs(1), move || {
                let Some(window) = window_weak.upgrade() else {
                    return glib::ControlFlow::Break;
                };
                if changed.swap(false, std::sync::atomic::Ordering::Relaxed) {
                    window.trigger_scan();
                }
                glib::ControlFlow::Continue
            });

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 28: Add GSettings choices/range constraints

Files:

  • Modify: data/app.driftwood.Driftwood.gschema.xml

Step 1: Add choices to enumerated string keys

For view-mode key, add choices:

    <key name="view-mode" type="s">
      <choices>
        <choice value='grid'/>
        <choice value='list'/>
      </choices>
      <default>'grid'</default>

For color-scheme key:

      <choices>
        <choice value='default'/>
        <choice value='force-light'/>
        <choice value='force-dark'/>
      </choices>

For detail-tab key:

      <choices>
        <choice value='overview'/>
        <choice value='system'/>
        <choice value='security'/>
        <choice value='storage'/>
      </choices>

For update-cleanup key:

      <choices>
        <choice value='ask'/>
        <choice value='always'/>
        <choice value='never'/>
      </choices>

For security-notification-threshold key:

      <choices>
        <choice value='critical'/>
        <choice value='high'/>
        <choice value='medium'/>
        <choice value='low'/>
      </choices>

For backup-retention-days, add range:

    <key name="backup-retention-days" type="i">
      <range min="1" max="365"/>
      <default>30</default>

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 29: Remove unused CSS classes

Files:

  • Modify: data/resources/style.css

Step 1: Remove unused class definitions

Remove these blocks:

  • .badge-row (lines 120-123) - defined but never added to any widget
  • .detail-view-switcher (lines 154-158) - defined but never added
  • .quick-action-pill (lines 160-164) - defined but never added

Keep .letter-icon (lines 125-130) since the per-letter variants in widgets.rs inherit these base properties through CSS specificity.

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 30: Define CSS for status-ok/status-attention or remove from code

Files:

  • Modify: data/resources/style.css (add CSS rules)

Step 1: Add CSS rules for the card status classes

Add after the .card styles:

/* App card status indicators */
.status-ok {
    border: 1px solid alpha(@success_bg_color, 0.4);
}

.status-attention {
    border: 1px solid alpha(@warning_bg_color, 0.4);
}

Step 2: Build and verify

Run: cargo build 2>&1 | grep "warning\|error" Expected: Clean build


Task 31: Tier 4 build gate + final verification

Run: cargo build 2>&1 | grep "warning\|error" Expected: Zero output

Run: cargo run and verify app launches.


Verification Checklist

After all tasks:

  1. cargo build - zero errors, zero warnings
  2. cargo run - app launches and shows library view
  3. Navigate to preferences, toggle settings, verify they save
  4. Open a detail view, switch tabs, reopen - tab should be restored