ntfs-mac-core 0.1.4

Core library for ntfs-mac: subprocess wrappers around ntfs-3g, diskutil, hdiutil, mkntfs, ntfsfix.
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 kodephp contributors

//! Format NTFS volumes. Destructive: requires a matching
//! [`DestructiveToken`].
//!
//! Prefers `newfs_ntfs` (from ntfs-3g) over `diskutil eraseVolume`
//! because the latter cannot produce a genuine NTFS volume.

use std::time::Duration;

use serde::{Deserialize, Serialize};

use crate::error::{Error, Result};
use crate::runner::{RunOptions, run_expect_success};
use crate::{Config, DestructiveToken, Volume};

/// Options for `format`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatOptions {
    /// Volume label (NTFS max 11 chars, but we accept longer and warn).
    pub label: String,
    /// Quick format (no full surface write). Default `true`.
    #[serde(default = "default_quick")]
    pub quick: bool,
    /// Sector size in bytes (512 or 4096). Default `4096` (GPT default).
    #[serde(default = "default_sector_size")]
    pub sector_size: u32,
    /// Cluster size in bytes (usually 4096).
    #[serde(default = "default_cluster_size")]
    pub cluster_size: u32,
    /// Extra ntfs-3g `newfs_ntfs` arguments.
    #[serde(default)]
    pub extra_args: Vec<String>,
}

impl Default for FormatOptions {
    fn default() -> Self {
        Self {
            label: String::new(),
            quick: true,
            sector_size: 4096,
            cluster_size: 4096,
            extra_args: Vec::new(),
        }
    }
}

fn default_quick() -> bool {
    true
}
fn default_sector_size() -> u32 {
    4096
}
fn default_cluster_size() -> u32 {
    4096
}

/// Format `vol` as NTFS. Requires `token.device_identifier` to match
/// `vol.device_identifier`.
pub fn format(
    vol: &Volume,
    opts: &FormatOptions,
    token: &DestructiveToken,
    _cfg: &Config,
) -> Result<()> {
    if vol.device_identifier != token.device_identifier {
        return Err(Error::ConfirmationMismatch {
            expected: token.device_identifier.clone(),
            actual: vol.device_identifier.clone(),
        });
    }
    if !vol.device_identifier.starts_with("disk") {
        return Err(Error::InvalidArgument(format!(
            "refusing to format `{}` — identifier must start with 'disk'",
            vol.device_identifier
        )));
    }

    // Validate sector_size and cluster_size are sensible values.
    if opts.sector_size != 512 && opts.sector_size != 4096 {
        return Err(Error::InvalidArgument(format!(
            "invalid sector_size {}: expected 512 or 4096",
            opts.sector_size
        )));
    }
    if !matches!(
        opts.cluster_size,
        512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768 | 65536
    ) {
        return Err(Error::InvalidArgument(format!(
            "invalid cluster_size {}: expected power-of-2 between 512 and 65536",
            opts.cluster_size
        )));
    }

    // Prefer newfs_ntfs; fall back to mkntfs if not present.
    let (bin_name, bin_path) = match crate::runner::which("newfs_ntfs") {
        Ok(p) => ("newfs_ntfs", p),
        Err(_) => {
            let p = crate::runner::which("mkntfs")?;
            ("mkntfs", p)
        }
    };

    let mut args = build_mkntfs_args(opts);
    args.push(format!("/dev/{}", vol.device_identifier));

    let bin_str = bin_path.to_str().unwrap_or(bin_name);
    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();

    run_expect_success(
        bin_str,
        &arg_refs,
        &RunOptions {
            timeout: Some(Duration::from_secs(600)),
            ..Default::default()
        },
    )?;
    Ok(())
}

/// Build the `mkntfs`/`newfs_ntfs` argument list from the options.
///
/// Flag mapping follows mkntfs(8) exactly:
///
/// * `-f`  — quick format (boolean; skips zeroing + bad-sector scan). The
///   previous implementation wrongly used `-F` (force) for this and passed
///   the sector size as `-f <n>`, which mkntfs reads as a stray positional
///   operand — the command could never have succeeded.
/// * `-s <n>` — sector size, always passed so user intent is explicit.
/// * `-c <n>` — cluster size, always passed.
/// * `-L <label>` — volume label.
pub fn build_mkntfs_args(opts: &FormatOptions) -> Vec<String> {
    let mut args: Vec<String> = Vec::new();
    if opts.quick {
        args.push("-f".into());
    }
    args.push("-s".into());
    args.push(opts.sector_size.to_string());
    args.push("-c".into());
    args.push(opts.cluster_size.to_string());
    if !opts.label.is_empty() {
        args.push("-L".into());
        args.push(opts.label.clone());
    }
    for a in &opts.extra_args {
        args.push(a.clone());
    }
    args
}

/// Validate a volume label for NTFS (max 11 chars, no special
/// characters). Returns a warning string, or `None` if clean.
pub fn validate_label(label: &str) -> Option<String> {
    if label.is_empty() {
        return None;
    }
    if label.len() > 11 {
        return Some(format!(
            "NTFS volume labels are limited to 11 characters; your label is {} chars",
            label.chars().count()
        ));
    }
    if label.contains([':', '\\', '/', '*', '?', '"', '<', '>', '|']) {
        return Some("label contains invalid NTFS characters: : \\ / * ? \" < > |".into());
    }
    None
}

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

    #[test]
    fn validate_label_lengths() {
        assert!(validate_label("MyData").is_none());
        assert!(validate_label("ABCDEFGHIJK").is_none()); // 11
        assert!(validate_label("ABCDEFGHIJKL").is_some()); // 12
        assert!(validate_label("").is_none());
    }

    #[test]
    fn validate_label_invalid_chars() {
        assert!(validate_label("a:b").is_some());
        assert!(validate_label("a\\b").is_some());
        assert!(validate_label("a/b").is_some());
        assert!(validate_label("a*b").is_some());
        assert!(validate_label("a?b").is_some());
        assert!(validate_label("a\"b").is_some());
        assert!(validate_label("a<b").is_some());
        assert!(validate_label("a>b").is_some());
        assert!(validate_label("a|b").is_some());
    }

    #[test]
    fn mkntfs_args_quick_with_explicit_sizes() {
        let opts = FormatOptions {
            label: "Data".into(),
            quick: true,
            sector_size: 512,
            cluster_size: 4096,
            extra_args: Vec::new(),
        };
        let args = build_mkntfs_args(&opts);
        assert_eq!(args, vec!["-f", "-s", "512", "-c", "4096", "-L", "Data"]);
    }

    #[test]
    fn mkntfs_args_full_format_has_no_fast_flag() {
        let opts = FormatOptions {
            label: String::new(),
            quick: false,
            sector_size: 4096,
            cluster_size: 8192,
            extra_args: vec!["-v".into()],
        };
        let args = build_mkntfs_args(&opts);
        assert_eq!(args, vec!["-s", "4096", "-c", "8192", "-v"]);
        assert!(!args.contains(&"-f".to_string()));
    }

    #[test]
    fn format_rejects_identifier_mismatch() {
        let vol = Volume {
            device_identifier: "disk2s2".into(),
            volume_name: "X".into(),
            media_type: "com.microsoft.ntfs".into(),
            uuid: None,
            size_bytes: 0,
            mounted: false,
            mount_point: None,
            parent_disk: None,
            size_pretty: "0 B".into(),
            location: "external".into(),
            contents: None,
        };
        let cfg = Config::default();
        let opts = FormatOptions::default();
        let token = DestructiveToken::new("disk999s1", "wrong");
        let r = format(&vol, &opts, &token, &cfg);
        assert!(matches!(r, Err(Error::ConfirmationMismatch { .. })));
    }

    #[test]
    fn format_rejects_non_disk_identifier() {
        let vol = Volume {
            device_identifier: "notdisk".into(),
            volume_name: "X".into(),
            media_type: "com.microsoft.ntfs".into(),
            uuid: None,
            size_bytes: 0,
            mounted: false,
            mount_point: None,
            parent_disk: None,
            size_pretty: "0 B".into(),
            location: "external".into(),
            contents: None,
        };
        let cfg = Config::default();
        let opts = FormatOptions::default();
        let token = DestructiveToken::new("notdisk", "");
        let r = format(&vol, &opts, &token, &cfg);
        assert!(matches!(r, Err(Error::InvalidArgument(_))));
    }

    #[test]
    fn format_rejects_invalid_sector_size() {
        let vol = Volume {
            device_identifier: "disk2s2".into(),
            volume_name: "X".into(),
            media_type: "com.microsoft.ntfs".into(),
            uuid: None,
            size_bytes: 0,
            mounted: false,
            mount_point: None,
            parent_disk: None,
            size_pretty: "0 B".into(),
            location: "external".into(),
            contents: None,
        };
        let cfg = Config::default();
        let opts = FormatOptions {
            sector_size: 2048, // invalid
            ..Default::default()
        };
        let token = DestructiveToken::new("disk2s2", "");
        let r = format(&vol, &opts, &token, &cfg);
        assert!(matches!(r, Err(Error::InvalidArgument(_))));
    }

    #[test]
    fn format_rejects_invalid_cluster_size() {
        let vol = Volume {
            device_identifier: "disk2s2".into(),
            volume_name: "X".into(),
            media_type: "com.microsoft.ntfs".into(),
            uuid: None,
            size_bytes: 0,
            mounted: false,
            mount_point: None,
            parent_disk: None,
            size_pretty: "0 B".into(),
            location: "external".into(),
            contents: None,
        };
        let cfg = Config::default();
        let opts = FormatOptions {
            cluster_size: 1000, // not a power of 2
            ..Default::default()
        };
        let token = DestructiveToken::new("disk2s2", "");
        let r = format(&vol, &opts, &token, &cfg);
        assert!(matches!(r, Err(Error::InvalidArgument(_))));
    }
}