Skip to main content

ntfs_mac_core/
format.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 kodephp contributors
3
4//! Format NTFS volumes. Destructive: requires a matching
5//! [`DestructiveToken`].
6//!
7//! Prefers `newfs_ntfs` (from ntfs-3g) over `diskutil eraseVolume`
8//! because the latter cannot produce a genuine NTFS volume.
9
10use std::time::Duration;
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{Error, Result};
15use crate::runner::{RunOptions, run_expect_success};
16use crate::{Config, DestructiveToken, Volume};
17
18/// Options for `format`.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct FormatOptions {
21    /// Volume label (NTFS max 11 chars, but we accept longer and warn).
22    pub label: String,
23    /// Quick format (no full surface write). Default `true`.
24    #[serde(default = "default_quick")]
25    pub quick: bool,
26    /// Sector size in bytes (512 or 4096). Default `4096` (GPT default).
27    #[serde(default = "default_sector_size")]
28    pub sector_size: u32,
29    /// Cluster size in bytes (usually 4096).
30    #[serde(default = "default_cluster_size")]
31    pub cluster_size: u32,
32    /// Extra ntfs-3g `newfs_ntfs` arguments.
33    #[serde(default)]
34    pub extra_args: Vec<String>,
35}
36
37impl Default for FormatOptions {
38    fn default() -> Self {
39        Self {
40            label: String::new(),
41            quick: true,
42            sector_size: 4096,
43            cluster_size: 4096,
44            extra_args: Vec::new(),
45        }
46    }
47}
48
49fn default_quick() -> bool {
50    true
51}
52fn default_sector_size() -> u32 {
53    4096
54}
55fn default_cluster_size() -> u32 {
56    4096
57}
58
59/// Format `vol` as NTFS. Requires `token.device_identifier` to match
60/// `vol.device_identifier`.
61pub fn format(
62    vol: &Volume,
63    opts: &FormatOptions,
64    token: &DestructiveToken,
65    _cfg: &Config,
66) -> Result<()> {
67    if vol.device_identifier != token.device_identifier {
68        return Err(Error::ConfirmationMismatch {
69            expected: token.device_identifier.clone(),
70            actual: vol.device_identifier.clone(),
71        });
72    }
73    if !vol.device_identifier.starts_with("disk") {
74        return Err(Error::InvalidArgument(format!(
75            "refusing to format `{}` — identifier must start with 'disk'",
76            vol.device_identifier
77        )));
78    }
79
80    // Validate sector_size and cluster_size are sensible values.
81    if opts.sector_size != 512 && opts.sector_size != 4096 {
82        return Err(Error::InvalidArgument(format!(
83            "invalid sector_size {}: expected 512 or 4096",
84            opts.sector_size
85        )));
86    }
87    if !matches!(
88        opts.cluster_size,
89        512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768 | 65536
90    ) {
91        return Err(Error::InvalidArgument(format!(
92            "invalid cluster_size {}: expected power-of-2 between 512 and 65536",
93            opts.cluster_size
94        )));
95    }
96
97    // Prefer newfs_ntfs; fall back to mkntfs if not present.
98    let (bin_name, bin_path) = match crate::runner::which("newfs_ntfs") {
99        Ok(p) => ("newfs_ntfs", p),
100        Err(_) => {
101            let p = crate::runner::which("mkntfs")?;
102            ("mkntfs", p)
103        }
104    };
105
106    let mut args = build_mkntfs_args(opts);
107    args.push(format!("/dev/{}", vol.device_identifier));
108
109    let bin_str = bin_path.to_str().unwrap_or(bin_name);
110    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
111
112    run_expect_success(
113        bin_str,
114        &arg_refs,
115        &RunOptions {
116            timeout: Some(Duration::from_secs(600)),
117            ..Default::default()
118        },
119    )?;
120    Ok(())
121}
122
123/// Build the `mkntfs`/`newfs_ntfs` argument list from the options.
124///
125/// Flag mapping follows mkntfs(8) exactly:
126///
127/// * `-f`  — quick format (boolean; skips zeroing + bad-sector scan). The
128///   previous implementation wrongly used `-F` (force) for this and passed
129///   the sector size as `-f <n>`, which mkntfs reads as a stray positional
130///   operand — the command could never have succeeded.
131/// * `-s <n>` — sector size, always passed so user intent is explicit.
132/// * `-c <n>` — cluster size, always passed.
133/// * `-L <label>` — volume label.
134pub fn build_mkntfs_args(opts: &FormatOptions) -> Vec<String> {
135    let mut args: Vec<String> = Vec::new();
136    if opts.quick {
137        args.push("-f".into());
138    }
139    args.push("-s".into());
140    args.push(opts.sector_size.to_string());
141    args.push("-c".into());
142    args.push(opts.cluster_size.to_string());
143    if !opts.label.is_empty() {
144        args.push("-L".into());
145        args.push(opts.label.clone());
146    }
147    for a in &opts.extra_args {
148        args.push(a.clone());
149    }
150    args
151}
152
153/// Validate a volume label for NTFS (max 11 chars, no special
154/// characters). Returns a warning string, or `None` if clean.
155pub fn validate_label(label: &str) -> Option<String> {
156    if label.is_empty() {
157        return None;
158    }
159    if label.len() > 11 {
160        return Some(format!(
161            "NTFS volume labels are limited to 11 characters; your label is {} chars",
162            label.chars().count()
163        ));
164    }
165    if label.contains([':', '\\', '/', '*', '?', '"', '<', '>', '|']) {
166        return Some("label contains invalid NTFS characters: : \\ / * ? \" < > |".into());
167    }
168    None
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn validate_label_lengths() {
177        assert!(validate_label("MyData").is_none());
178        assert!(validate_label("ABCDEFGHIJK").is_none()); // 11
179        assert!(validate_label("ABCDEFGHIJKL").is_some()); // 12
180        assert!(validate_label("").is_none());
181    }
182
183    #[test]
184    fn validate_label_invalid_chars() {
185        assert!(validate_label("a:b").is_some());
186        assert!(validate_label("a\\b").is_some());
187        assert!(validate_label("a/b").is_some());
188        assert!(validate_label("a*b").is_some());
189        assert!(validate_label("a?b").is_some());
190        assert!(validate_label("a\"b").is_some());
191        assert!(validate_label("a<b").is_some());
192        assert!(validate_label("a>b").is_some());
193        assert!(validate_label("a|b").is_some());
194    }
195
196    #[test]
197    fn mkntfs_args_quick_with_explicit_sizes() {
198        let opts = FormatOptions {
199            label: "Data".into(),
200            quick: true,
201            sector_size: 512,
202            cluster_size: 4096,
203            extra_args: Vec::new(),
204        };
205        let args = build_mkntfs_args(&opts);
206        assert_eq!(args, vec!["-f", "-s", "512", "-c", "4096", "-L", "Data"]);
207    }
208
209    #[test]
210    fn mkntfs_args_full_format_has_no_fast_flag() {
211        let opts = FormatOptions {
212            label: String::new(),
213            quick: false,
214            sector_size: 4096,
215            cluster_size: 8192,
216            extra_args: vec!["-v".into()],
217        };
218        let args = build_mkntfs_args(&opts);
219        assert_eq!(args, vec!["-s", "4096", "-c", "8192", "-v"]);
220        assert!(!args.contains(&"-f".to_string()));
221    }
222
223    #[test]
224    fn format_rejects_identifier_mismatch() {
225        let vol = Volume {
226            device_identifier: "disk2s2".into(),
227            volume_name: "X".into(),
228            media_type: "com.microsoft.ntfs".into(),
229            uuid: None,
230            size_bytes: 0,
231            mounted: false,
232            mount_point: None,
233            parent_disk: None,
234            size_pretty: "0 B".into(),
235            location: "external".into(),
236            contents: None,
237        };
238        let cfg = Config::default();
239        let opts = FormatOptions::default();
240        let token = DestructiveToken::new("disk999s1", "wrong");
241        let r = format(&vol, &opts, &token, &cfg);
242        assert!(matches!(r, Err(Error::ConfirmationMismatch { .. })));
243    }
244
245    #[test]
246    fn format_rejects_non_disk_identifier() {
247        let vol = Volume {
248            device_identifier: "notdisk".into(),
249            volume_name: "X".into(),
250            media_type: "com.microsoft.ntfs".into(),
251            uuid: None,
252            size_bytes: 0,
253            mounted: false,
254            mount_point: None,
255            parent_disk: None,
256            size_pretty: "0 B".into(),
257            location: "external".into(),
258            contents: None,
259        };
260        let cfg = Config::default();
261        let opts = FormatOptions::default();
262        let token = DestructiveToken::new("notdisk", "");
263        let r = format(&vol, &opts, &token, &cfg);
264        assert!(matches!(r, Err(Error::InvalidArgument(_))));
265    }
266
267    #[test]
268    fn format_rejects_invalid_sector_size() {
269        let vol = Volume {
270            device_identifier: "disk2s2".into(),
271            volume_name: "X".into(),
272            media_type: "com.microsoft.ntfs".into(),
273            uuid: None,
274            size_bytes: 0,
275            mounted: false,
276            mount_point: None,
277            parent_disk: None,
278            size_pretty: "0 B".into(),
279            location: "external".into(),
280            contents: None,
281        };
282        let cfg = Config::default();
283        let opts = FormatOptions {
284            sector_size: 2048, // invalid
285            ..Default::default()
286        };
287        let token = DestructiveToken::new("disk2s2", "");
288        let r = format(&vol, &opts, &token, &cfg);
289        assert!(matches!(r, Err(Error::InvalidArgument(_))));
290    }
291
292    #[test]
293    fn format_rejects_invalid_cluster_size() {
294        let vol = Volume {
295            device_identifier: "disk2s2".into(),
296            volume_name: "X".into(),
297            media_type: "com.microsoft.ntfs".into(),
298            uuid: None,
299            size_bytes: 0,
300            mounted: false,
301            mount_point: None,
302            parent_disk: None,
303            size_pretty: "0 B".into(),
304            location: "external".into(),
305            contents: None,
306        };
307        let cfg = Config::default();
308        let opts = FormatOptions {
309            cluster_size: 1000, // not a power of 2
310            ..Default::default()
311        };
312        let token = DestructiveToken::new("disk2s2", "");
313        let r = format(&vol, &opts, &token, &cfg);
314        assert!(matches!(r, Err(Error::InvalidArgument(_))));
315    }
316}