Skip to main content

ntfs_mac_core/
format.rs

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