Skip to main content

ironoxide/internal/document_api/
file_ops.rs

1//! File-based document encryption and decryption operations.
2//!
3//! This module provides streaming file encryption/decryption with constant memory usage.
4//! The encrypted format is identical to the in-memory document encryption, ensuring
5//! full interoperability between file and memory operations.
6
7use crate::{
8    PolicyCache, Result,
9    config::IronOxideConfig,
10    crypto::{aes::AES_KEY_LEN, streaming, transform},
11    group::GroupId,
12    internal::{
13        IronOxideErr, PrivateKey, PublicKey, PublicKeyCache, RequestAuth,
14        document_api::{
15            self, DocAccessEditErr, DocumentHeader, DocumentId, DocumentName, UserOrGroup,
16            parse_header_length, recrypt_document,
17            requests::{self, document_create},
18        },
19    },
20    policy::PolicyGrant,
21    proto::transform::EncryptedDeks as EncryptedDeksP,
22    user::UserId,
23};
24use protobuf::Message;
25use rand::CryptoRng;
26use recrypt::prelude::*;
27use std::{
28    fs::{self, File, OpenOptions},
29    io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write},
30    sync::Mutex,
31};
32use time::OffsetDateTime;
33
34/// On Unix: mode 0600 (owner read/write only).
35/// On Windows: share_mode(0) prevents other processes from accessing while open.
36/// On anything else (wasm?): we don't have a clean method of restricting access during decryption.
37fn create_output_file(path: &str) -> Result<File> {
38    #[cfg(unix)]
39    {
40        use std::os::unix::fs::OpenOptionsExt;
41        OpenOptions::new()
42            .write(true)
43            .create(true)
44            .truncate(true)
45            .mode(0o600)
46            .open(path)
47            .map_err(|e| IronOxideErr::FileIoError {
48                path: Some(path.to_string()),
49                operation: "create".into(),
50                message: e.to_string(),
51            })
52    }
53
54    #[cfg(windows)]
55    {
56        use std::os::windows::fs::OpenOptionsExt;
57        OpenOptions::new()
58            .write(true)
59            .create(true)
60            .truncate(true)
61            .share_mode(0)
62            .open(path)
63            .map_err(|e| IronOxideErr::FileIoError {
64                path: Some(path.to_string()),
65                operation: "create".into(),
66                message: e.to_string(),
67            })
68    }
69
70    #[cfg(not(any(unix, windows)))]
71    {
72        OpenOptions::new()
73            .write(true)
74            .create(true)
75            .truncate(true)
76            .open(path)
77            .map_err(|e| IronOxideErr::FileIoError {
78                path: Some(path.to_string()),
79                operation: "create".into(),
80                message: e.to_string(),
81            })
82    }
83}
84
85/// On Unix: changes mode from 0600 to 0644 (owner read/write, group/other read).
86/// On other platforms: no-op (Windows share_mode releases automatically on close, there was nothing to remove on wasm).
87fn reset_file_permissions(path: &str) -> Result<()> {
88    #[cfg(unix)]
89    {
90        use fs::Permissions;
91        use std::os::unix::fs::PermissionsExt;
92        fs::set_permissions(path, Permissions::from_mode(0o644)).map_err(|e| {
93            IronOxideErr::FileIoError {
94                path: Some(path.to_string()),
95                operation: "set_permissions".into(),
96                message: e.to_string(),
97            }
98        })
99    }
100
101    #[cfg(not(unix))]
102    {
103        // suppress unused warning
104        let _ = path;
105        Ok(())
106    }
107}
108
109/// Used during decryption to ensure unauthenticated plaintext is cleaned up if verification fails or an error/panic
110/// occurs before completion.
111struct CleanupOnDrop {
112    path: String,
113    committed: bool,
114}
115
116impl CleanupOnDrop {
117    fn new(path: &str) -> Self {
118        Self {
119            path: path.to_string(),
120            committed: false,
121        }
122    }
123
124    /// Commit the file, which prevents deletion on drop. Should be done after verification.
125    fn commit(mut self) {
126        self.committed = true;
127    }
128}
129
130impl Drop for CleanupOnDrop {
131    fn drop(&mut self) {
132        if !self.committed {
133            // _ is to intentionally ignore failure here, there's nothing we or the caller can do if we can't remove the
134            // file (already removed, moved, permissions failure, etc).
135            let _ = fs::remove_file(&self.path);
136        }
137    }
138}
139
140/// Open a source file and read/parse the IronCore document header.
141/// Returns the parsed header and the file positioned after the header.
142fn read_document_header(source_path: &str) -> Result<(DocumentHeader, File)> {
143    let mut source_file = File::open(source_path).map_err(|e| IronOxideErr::FileIoError {
144        path: Some(source_path.to_string()),
145        operation: "open".into(),
146        message: e.to_string(),
147    })?;
148
149    // Read first few bytes to determine header length
150    let mut header_prefix = [0u8; 3];
151    source_file
152        .read_exact(&mut header_prefix)
153        .map_err(|e| IronOxideErr::FileIoError {
154            path: Some(source_path.to_string()),
155            operation: "read_header".into(),
156            message: e.to_string(),
157        })?;
158
159    let header_len = parse_header_length(&header_prefix)?;
160
161    // Read full header from beginning
162    source_file
163        .seek(SeekFrom::Start(0))
164        .map_err(|e| IronOxideErr::FileIoError {
165            path: Some(source_path.to_string()),
166            operation: "seek".into(),
167            message: e.to_string(),
168        })?;
169
170    let mut header_bytes = vec![0u8; header_len];
171    source_file
172        .read_exact(&mut header_bytes)
173        .map_err(|e| IronOxideErr::FileIoError {
174            path: Some(source_path.to_string()),
175            operation: "read_header".into(),
176            message: e.to_string(),
177        })?;
178
179    // Parse header JSON (skip 3-byte prefix)
180    let doc_header: DocumentHeader =
181        serde_json::from_slice(&header_bytes[3..header_len]).map_err(|_| {
182            IronOxideErr::DocumentHeaderParseFailure(
183                "Unable to parse document header. Header value is corrupted.".to_string(),
184            )
185        })?;
186
187    Ok((doc_header, source_file))
188}
189
190/// Stream decrypt from source file to destination, handling cleanup on failure.
191/// Creates output with restrictive permissions, streams decryption, verifies tag,
192/// and resets permissions on success.
193fn stream_decrypt_to_file(
194    key_bytes: &[u8; AES_KEY_LEN],
195    source_file: &File,
196    destination_path: &str,
197) -> Result<()> {
198    let mut output_file = create_output_file(destination_path)?;
199    let cleanup_guard = CleanupOnDrop::new(destination_path);
200
201    let mut reader = BufReader::new(source_file);
202    let mut writer = BufWriter::new(&mut output_file);
203
204    streaming::decrypt_stream(key_bytes, &mut reader, &mut writer)?;
205
206    writer.flush().map_err(|e| IronOxideErr::FileIoError {
207        path: Some(destination_path.to_string()),
208        operation: "flush".into(),
209        message: e.to_string(),
210    })?;
211
212    // Verification succeeded - commit the file (prevents deletion on drop)
213    cleanup_guard.commit();
214    reset_file_permissions(destination_path)?;
215
216    Ok(())
217}
218
219/// Result of file encryption (managed).
220///
221/// Produced by [document_file_encrypt](trait.DocumentFileOps.html#tymethod.document_file_encrypt).
222#[derive(Clone, Debug, Eq, Hash, PartialEq)]
223pub struct DocumentFileEncryptResult {
224    id: DocumentId,
225    name: Option<DocumentName>,
226    created: OffsetDateTime,
227    updated: OffsetDateTime,
228    grants: Vec<UserOrGroup>,
229    access_errs: Vec<DocAccessEditErr>,
230}
231
232impl DocumentFileEncryptResult {
233    /// ID of the encrypted document
234    pub fn id(&self) -> &DocumentId {
235        &self.id
236    }
237
238    /// Name of the document
239    pub fn name(&self) -> Option<&DocumentName> {
240        self.name.as_ref()
241    }
242
243    /// Date and time when the document was created
244    pub fn created(&self) -> &OffsetDateTime {
245        &self.created
246    }
247
248    /// Date and time when the document was last updated
249    pub fn last_updated(&self) -> &OffsetDateTime {
250        &self.updated
251    }
252    /// Users and groups the document was successfully encrypted to
253    pub fn grants(&self) -> &[UserOrGroup] {
254        &self.grants
255    }
256
257    /// Errors resulting from failure to encrypt to specific users/groups
258    pub fn access_errs(&self) -> &[DocAccessEditErr] {
259        &self.access_errs
260    }
261}
262
263/// Result of file encryption (unmanaged).
264///
265/// Produced by [document_file_encrypt_unmanaged](trait.DocumentFileUnmanagedOps.html#tymethod.document_file_encrypt_unmanaged).
266#[derive(Clone, Debug, Eq, Hash, PartialEq)]
267pub struct DocumentFileEncryptUnmanagedResult {
268    id: DocumentId,
269    encrypted_deks: Vec<u8>,
270    grants: Vec<UserOrGroup>,
271    access_errs: Vec<DocAccessEditErr>,
272}
273
274impl DocumentFileEncryptUnmanagedResult {
275    /// ID of the encrypted document
276    pub fn id(&self) -> &DocumentId {
277        &self.id
278    }
279
280    /// Bytes of EDEKs of users/groups that have been granted access
281    pub fn encrypted_deks(&self) -> &[u8] {
282        &self.encrypted_deks
283    }
284
285    /// Users and groups the document was successfully encrypted to
286    pub fn grants(&self) -> &[UserOrGroup] {
287        &self.grants
288    }
289
290    /// Errors resulting from failure to encrypt to specific users/groups
291    pub fn access_errs(&self) -> &[DocAccessEditErr] {
292        &self.access_errs
293    }
294}
295
296/// Result of file decryption (managed).
297///
298/// Produced by [document_file_decrypt](trait.DocumentFileOps.html#tymethod.document_file_decrypt).
299#[derive(Clone, Debug, Eq, Hash, PartialEq)]
300pub struct DocumentFileDecryptResult {
301    id: DocumentId,
302    name: Option<DocumentName>,
303}
304
305impl DocumentFileDecryptResult {
306    /// ID of the decrypted document
307    pub fn id(&self) -> &DocumentId {
308        &self.id
309    }
310
311    /// Name of the document
312    pub fn name(&self) -> Option<&DocumentName> {
313        self.name.as_ref()
314    }
315}
316
317/// Result of file decryption (unmanaged).
318///
319/// Produced by [document_file_decrypt_unmanaged](trait.DocumentFileUnmanagedOps.html#tymethod.document_file_decrypt_unmanaged).
320#[derive(Clone, Debug, Eq, Hash, PartialEq)]
321pub struct DocumentFileDecryptUnmanagedResult {
322    id: DocumentId,
323    access_via: UserOrGroup,
324}
325
326impl DocumentFileDecryptUnmanagedResult {
327    /// ID of the decrypted document
328    pub fn id(&self) -> &DocumentId {
329        &self.id
330    }
331
332    /// User or group that granted access to the encrypted data
333    pub fn access_via(&self) -> &UserOrGroup {
334        &self.access_via
335    }
336}
337
338/// Encrypt a file from source path to destination path.
339/// Uses streaming I/O with constant memory. Output format is identical to `document_encrypt`.
340pub async fn encrypt_file_to_path<R1, R2>(
341    auth: &RequestAuth,
342    config: &IronOxideConfig,
343    recrypt: &Recrypt<Sha256, Ed25519, RandomBytes<R1>>,
344    user_master_pub_key: &PublicKey,
345    rng: &Mutex<R2>,
346    source_path: &str,
347    destination_path: &str,
348    document_id: Option<DocumentId>,
349    document_name: Option<DocumentName>,
350    grant_to_author: bool,
351    user_grants: &[UserId],
352    group_grants: &[GroupId],
353    policy_grant: Option<&PolicyGrant>,
354    policy_cache: &PolicyCache,
355    public_key_cache: &PublicKeyCache,
356) -> Result<DocumentFileEncryptResult>
357where
358    R1: CryptoRng,
359    R2: CryptoRng,
360{
361    let source_file = File::open(source_path).map_err(|e| IronOxideErr::FileIoError {
362        path: Some(source_path.to_string()),
363        operation: "open".into(),
364        message: e.to_string(),
365    })?;
366    let (dek, doc_sym_key) = transform::generate_new_doc_key(recrypt);
367    let doc_id = document_id.unwrap_or_else(|| DocumentId::goo_id(rng));
368    let (grants, key_errs) = document_api::resolve_keys_for_grants(
369        auth,
370        config,
371        user_grants,
372        group_grants,
373        policy_grant,
374        if grant_to_author {
375            Some(user_master_pub_key)
376        } else {
377            None
378        },
379        policy_cache,
380        public_key_cache,
381    )
382    .await?;
383    let mut output_file = create_output_file(destination_path)?;
384
385    // Write document header
386    let header = DocumentHeader::new(doc_id.clone(), auth.segment_id);
387    let header_bytes = header.pack();
388    output_file
389        .write_all(&header_bytes.0)
390        .map_err(|e| IronOxideErr::FileIoError {
391            path: Some(destination_path.to_string()),
392            operation: "write_header".into(),
393            message: e.to_string(),
394        })?;
395
396    let mut reader = BufReader::new(source_file);
397    let mut writer = BufWriter::new(&mut output_file);
398
399    // Stream encrypt the file content (writes IV + ciphertext + auth tag)
400    let key_bytes = *doc_sym_key.bytes();
401    streaming::encrypt_stream(&key_bytes, rng, &mut reader, &mut writer)?;
402    reset_file_permissions(destination_path)?;
403
404    // Encrypt DEK to all grantees
405    let recryption_result =
406        recrypt_document(&auth.signing_private_key, recrypt, dek, &doc_id, grants)?;
407
408    // Create document on server
409    let create_result = document_create::document_create_request(
410        auth,
411        doc_id,
412        document_name,
413        recryption_result.edeks,
414    )
415    .await?;
416
417    Ok(DocumentFileEncryptResult {
418        id: create_result.id,
419        name: create_result.name,
420        created: create_result.created,
421        updated: create_result.updated,
422        grants: create_result
423            .shared_with
424            .into_iter()
425            .map(|sw| sw.into())
426            .collect(),
427        access_errs: [key_errs, recryption_result.encryption_errs].concat(),
428    })
429}
430
431/// Decrypt an encrypted file to destination path.
432///
433/// Uses streaming I/O with constant memory.
434pub async fn decrypt_file_to_path<CR>(
435    auth: &RequestAuth,
436    recrypt: std::sync::Arc<Recrypt<Sha256, Ed25519, RandomBytes<CR>>>,
437    device_private_key: &PrivateKey,
438    source_path: &str,
439    destination_path: &str,
440) -> Result<DocumentFileDecryptResult>
441where
442    CR: CryptoRng + Send + Sync + 'static,
443{
444    let (doc_header, source_file) = read_document_header(source_path)?;
445
446    // Get document metadata from server
447    let doc_meta = document_api::document_get_metadata(auth, &doc_header.document_id).await?;
448
449    // Decrypt the symmetric key
450    let device_private_key = device_private_key.clone();
451    let encrypted_symmetric_key = doc_meta.to_encrypted_symmetric_key()?;
452
453    let sym_key = tokio::task::spawn_blocking(move || {
454        transform::decrypt_as_symmetric_key(
455            &recrypt,
456            encrypted_symmetric_key,
457            device_private_key.recrypt_key(),
458        )
459    })
460    .await??;
461
462    let key_bytes: [u8; AES_KEY_LEN] = *sym_key.bytes();
463    stream_decrypt_to_file(&key_bytes, &source_file, destination_path)?;
464
465    Ok(DocumentFileDecryptResult {
466        id: doc_meta.id().clone(),
467        name: doc_meta.name().cloned(),
468    })
469}
470
471/// Encrypt a file (unmanaged) - EDEKs are returned to caller instead of stored on server.
472pub async fn encrypt_file_unmanaged<R1, R2>(
473    auth: &RequestAuth,
474    config: &IronOxideConfig,
475    recrypt: &Recrypt<Sha256, Ed25519, RandomBytes<R1>>,
476    user_master_pub_key: &PublicKey,
477    rng: &Mutex<R2>,
478    source_path: &str,
479    destination_path: &str,
480    document_id: Option<DocumentId>,
481    grant_to_author: bool,
482    user_grants: &[UserId],
483    group_grants: &[GroupId],
484    policy_grant: Option<&PolicyGrant>,
485    policy_cache: &PolicyCache,
486    public_key_cache: &PublicKeyCache,
487) -> Result<DocumentFileEncryptUnmanagedResult>
488where
489    R1: CryptoRng,
490    R2: CryptoRng,
491{
492    // Open source file
493    let source_file = File::open(source_path).map_err(|e| IronOxideErr::FileIoError {
494        path: Some(source_path.to_string()),
495        operation: "open".into(),
496        message: e.to_string(),
497    })?;
498
499    // Generate keys
500    let (dek, doc_sym_key) = transform::generate_new_doc_key(recrypt);
501    let doc_id = document_id.unwrap_or_else(|| DocumentId::goo_id(rng));
502
503    // Resolve grants
504    let (grants, key_errs) = document_api::resolve_keys_for_grants(
505        auth,
506        config,
507        user_grants,
508        group_grants,
509        policy_grant,
510        if grant_to_author {
511            Some(user_master_pub_key)
512        } else {
513            None
514        },
515        policy_cache,
516        public_key_cache,
517    )
518    .await?;
519
520    // Create output file
521    let mut output_file = create_output_file(destination_path)?;
522
523    // Write document header
524    let header = DocumentHeader::new(doc_id.clone(), auth.segment_id);
525    let header_bytes = header.pack();
526    output_file
527        .write_all(&header_bytes.0)
528        .map_err(|e| IronOxideErr::FileIoError {
529            path: Some(destination_path.to_string()),
530            operation: "write_header".into(),
531            message: e.to_string(),
532        })?;
533
534    // Stream encrypt the file content (writes IV + ciphertext + auth tag)
535    let mut reader = BufReader::new(source_file);
536    let mut writer = BufWriter::new(&mut output_file);
537
538    let key_bytes: [u8; AES_KEY_LEN] = *doc_sym_key.bytes();
539    streaming::encrypt_stream(&key_bytes, rng, &mut reader, &mut writer)?;
540
541    // Encrypt DEK to all grantees
542    let r = recrypt_document(&auth.signing_private_key, recrypt, dek, &doc_id, grants)?;
543
544    // Convert EDEKs to bytes
545    let edek_bytes = document_api::edeks_to_bytes(&r.edeks, &doc_id, auth.segment_id)?;
546
547    let successful_grants: Vec<UserOrGroup> =
548        r.edeks.iter().map(|edek| edek.grant_to().clone()).collect();
549    let all_errs: Vec<DocAccessEditErr> = key_errs
550        .into_iter()
551        .chain(r.encryption_errs.clone())
552        .collect();
553
554    // Reset file permissions to normal (0644 on Unix)
555    reset_file_permissions(destination_path)?;
556
557    Ok(DocumentFileEncryptUnmanagedResult {
558        id: doc_id,
559        encrypted_deks: edek_bytes,
560        grants: successful_grants,
561        access_errs: all_errs,
562    })
563}
564
565/// Decrypt an encrypted file (unmanaged) - caller provides EDEKs.
566pub async fn decrypt_file_unmanaged<CR>(
567    auth: &RequestAuth,
568    recrypt: &Recrypt<Sha256, Ed25519, RandomBytes<CR>>,
569    device_private_key: &PrivateKey,
570    source_path: &str,
571    destination_path: &str,
572    encrypted_deks: &[u8],
573) -> Result<DocumentFileDecryptUnmanagedResult>
574where
575    CR: CryptoRng,
576{
577    let (doc_header, source_file) = read_document_header(source_path)?;
578
579    // Parse and verify EDEKs match document
580    let proto_edeks =
581        EncryptedDeksP::parse_from_bytes(encrypted_deks).map_err(IronOxideErr::from)?;
582    document_api::edeks_and_header_match_or_err(&proto_edeks, &doc_header)?;
583
584    // Transform EDEK
585    let transform_resp = requests::edek_transform::edek_transform(auth, encrypted_deks).await?;
586    let requests::edek_transform::EdekTransformResponse {
587        user_or_group,
588        encrypted_symmetric_key,
589    } = transform_resp;
590
591    // Decrypt the symmetric key
592    let sym_key = transform::decrypt_as_symmetric_key(
593        recrypt,
594        encrypted_symmetric_key.try_into()?,
595        device_private_key.recrypt_key(),
596    )?;
597
598    let key_bytes: [u8; AES_KEY_LEN] = *sym_key.bytes();
599    stream_decrypt_to_file(&key_bytes, &source_file, destination_path)?;
600
601    Ok(DocumentFileDecryptUnmanagedResult {
602        id: doc_header.document_id,
603        access_via: user_or_group,
604    })
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use std::fs;
611    use tempfile::NamedTempFile;
612
613    #[test]
614    fn cleanup_on_drop_deletes_uncommitted_file() {
615        let temp_file = NamedTempFile::new().expect("Failed to create temp file");
616        let path = temp_file.path().to_str().unwrap().to_string();
617
618        // Write some content so the file definitely exists
619        fs::write(&path, b"test content").expect("Failed to write");
620        assert!(fs::metadata(&path).is_ok(), "File should exist before drop");
621
622        // Create guard and drop it without committing
623        let guard = CleanupOnDrop::new(&path);
624        drop(guard);
625
626        // File should be deleted
627        assert!(
628            fs::metadata(&path).is_err(),
629            "File should be deleted after drop without commit"
630        );
631    }
632
633    #[test]
634    fn cleanup_on_drop_preserves_committed_file() {
635        let temp_file = NamedTempFile::new().expect("Failed to create temp file");
636        let path = temp_file.path().to_str().unwrap().to_string();
637
638        // Write some content
639        fs::write(&path, b"test content").expect("Failed to write");
640        assert!(fs::metadata(&path).is_ok(), "File should exist before drop");
641
642        // Create guard, commit it, then drop
643        let guard = CleanupOnDrop::new(&path);
644        guard.commit();
645
646        // File should still exist
647        assert!(
648            fs::metadata(&path).is_ok(),
649            "File should exist after committed drop"
650        );
651
652        // Clean up manually since we committed
653        let _ = fs::remove_file(&path);
654    }
655
656    #[test]
657    fn cleanup_on_drop_handles_already_deleted_file() {
658        let temp_file = NamedTempFile::new().expect("Failed to create temp file");
659        let path = temp_file.path().to_str().unwrap().to_string();
660
661        // Delete the file before the guard tries to clean up
662        let _ = fs::remove_file(&path);
663
664        // This should not panic even though file doesn't exist
665        let guard = CleanupOnDrop::new(&path);
666        drop(guard); // Should silently handle the missing file
667    }
668
669    #[cfg(unix)]
670    mod unix_permissions {
671        use super::*;
672        use std::os::unix::fs::PermissionsExt;
673
674        #[test]
675        fn create_output_file_sets_restrictive_permissions() {
676            let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
677            let path = temp_dir.path().join("test_output.txt");
678            let path_str = path.to_str().unwrap();
679
680            let file = create_output_file(path_str).expect("Failed to create file");
681            drop(file);
682
683            let metadata = fs::metadata(path_str).expect("Failed to get metadata");
684            let mode = metadata.permissions().mode() & 0o777;
685
686            assert_eq!(
687                mode, 0o600,
688                "File should have mode 0600 (owner read/write only)"
689            );
690
691            // Clean up
692            let _ = fs::remove_file(path_str);
693        }
694
695        #[test]
696        fn reset_file_permissions_sets_normal_permissions() {
697            let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
698            let path = temp_dir.path().join("test_reset.txt");
699            let path_str = path.to_str().unwrap();
700
701            // Create file with restrictive permissions
702            let file = create_output_file(path_str).expect("Failed to create file");
703            drop(file);
704
705            // Verify it starts with 0600
706            let metadata = fs::metadata(path_str).expect("Failed to get metadata");
707            let mode_before = metadata.permissions().mode() & 0o777;
708            assert_eq!(mode_before, 0o600);
709
710            // Reset permissions
711            reset_file_permissions(path_str).expect("Failed to reset permissions");
712
713            // Verify it's now 0644
714            let metadata = fs::metadata(path_str).expect("Failed to get metadata");
715            let mode_after = metadata.permissions().mode() & 0o777;
716            assert_eq!(mode_after, 0o644, "File should have mode 0644 after reset");
717
718            // Clean up
719            let _ = fs::remove_file(path_str);
720        }
721    }
722
723    #[cfg(windows)]
724    mod windows_permissions {
725        use super::*;
726
727        #[test]
728        fn create_output_file_has_exclusive_access_on_windows() {
729            use std::io::Write;
730
731            let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
732            let path = temp_dir.path().join("test_output.txt");
733            let path_str = path.to_str().unwrap();
734
735            // On Windows, create_output_file uses share_mode(0) which prevents
736            // other processes/handles from accessing while open
737            let mut file = create_output_file(path_str).expect("Failed to create file");
738
739            // Write something to the file
740            file.write_all(b"test").expect("Failed to write");
741
742            // While the file is still open, attempting to open it again should fail
743            // due to share_mode(0) denying all sharing
744            let open_attempt = fs::File::open(path_str);
745            assert!(
746                open_attempt.is_err(),
747                "Opening file should fail while held with exclusive share_mode(0)"
748            );
749
750            // Drop the original handle
751            drop(file);
752
753            // Now opening should succeed
754            let content = fs::read(path_str).expect("Failed to read file after handle released");
755            assert_eq!(content, b"test");
756
757            // Clean up
758            let _ = fs::remove_file(path_str);
759        }
760
761        #[test]
762        fn reset_file_permissions_is_noop_on_windows() {
763            let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
764            let path = temp_dir.path().join("test_reset.txt");
765            let path_str = path.to_str().unwrap();
766
767            // Create a file
768            fs::write(path_str, b"test content").expect("Failed to write file");
769
770            // reset_file_permissions should succeed (it's a no-op on Windows)
771            reset_file_permissions(path_str).expect("reset_file_permissions should succeed");
772
773            // File should still be readable
774            let content = fs::read(path_str).expect("Failed to read file");
775            assert_eq!(content, b"test content");
776
777            // Clean up
778            let _ = fs::remove_file(path_str);
779        }
780    }
781}