tempfile_ext/
lib.rs

1// Copyright 2023 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use std::fs::File;
6use std::io::{self, ErrorKind, Read as _, Seek as _, SeekFrom};
7use std::path::Path;
8use tempfile::{NamedTempFile, PersistError};
9
10const CHUNK_SIZE: usize = 8192;
11
12pub trait NamedTempFileExt {
13    /// Atomically overwrite the target path if the contents are different.
14    ///
15    /// This is conceptually similar to [tempfile::NamedTempFile::persist], but
16    /// will only overwrite the target path if:
17    ///
18    /// * The target path does not exist.
19    /// * The target path has different contents than `self`.
20    ///
21    /// It will not overwrite the target path if it already exists and has the
22    /// same content.
23    ///
24    /// Either way, it will return an opened `File` for the target path.
25    fn persist_if_changed<P: AsRef<Path>>(self, path: P) -> Result<File, PersistError>;
26}
27
28impl NamedTempFileExt for NamedTempFile {
29    fn persist_if_changed<P: AsRef<Path>>(mut self, path: P) -> Result<File, PersistError> {
30        let path = path.as_ref();
31
32        // Open up the file and see if the contents are the same. If so, return
33        // the old file, otherwise overwrite it with the new contents.
34        if let Ok(mut old_file) = File::open(&path) {
35            if let Err(err) = self.seek(SeekFrom::Start(0)) {
36                return Err(PersistError { error: err, file: self });
37            }
38
39            match files_have_same_content(self.as_file_mut(), &mut old_file) {
40                Ok(true) => {
41                    // Since the two files have the same contents, we'll return
42                    // the old one. We read from it, so reset back to the start.
43                    if let Err(err) = old_file.seek(SeekFrom::Start(0)) {
44                        return Err(PersistError { error: err, file: self });
45                    }
46
47                    return Ok(old_file);
48                }
49                Ok(false) => {}
50                Err(err) => {
51                    return Err(PersistError { error: err, file: self });
52                }
53            }
54        }
55
56        self.persist(path)
57    }
58}
59
60/// Checks if the two files have the same contents. Returns `true` if so,
61/// otherwise `false`.
62fn files_have_same_content(lhs: &mut File, rhs: &mut File) -> io::Result<bool> {
63    let lhs_metadata = lhs.metadata()?;
64    let rhs_metadata = rhs.metadata()?;
65
66    // The files cannot have the same content if they have different lengths.
67    if lhs_metadata.len() != rhs_metadata.len() {
68        return Ok(false);
69    }
70
71    files_after_checking_length_have_same_content(lhs, rhs)
72}
73
74/// Check the contents of two files have the same contents, after we have
75/// checked they have the same length. Returns `true` if they have the same
76/// contents, `false` if not.
77///
78/// This will return `false` if the files changed length while checking.
79fn files_after_checking_length_have_same_content(
80    lhs: &mut File,
81    rhs: &mut File,
82) -> io::Result<bool> {
83    let mut lhs_bytes = [0; CHUNK_SIZE];
84    let mut rhs_bytes = [0; CHUNK_SIZE];
85
86    loop {
87        // Read the next chunk.
88        let lhs_len = lhs.read(&mut lhs_bytes)?;
89        let lhs_bytes = &lhs_bytes[0..lhs_len];
90
91        if lhs_bytes.is_empty() {
92            // We hit the end of `lhs`, so we'll need to check if `rhs` is also
93            // at the end of the file. We can't use `read_exact` to check for
94            // this, since it's not guaranteed to read from the file if we pass
95            // it a zero-sized slice.
96            if rhs.read(&mut rhs_bytes)? == 0 {
97                return Ok(true);
98            } else {
99                return Ok(false);
100            }
101        }
102
103        // Otherwise, read the same length from the other file.
104        let rhs_bytes = &mut rhs_bytes[0..lhs_len];
105        match rhs.read_exact(rhs_bytes) {
106            Ok(()) => {}
107            Err(err) if err.kind() == ErrorKind::UnexpectedEof => {
108                // The file was truncated while we were reading from it, so
109                // return that the two files are different.
110                return Ok(false);
111            }
112            Err(err) => {
113                return Err(err);
114            }
115        }
116
117        // Return false if the two chunks are not the same.
118        if lhs_bytes != rhs_bytes {
119            return Ok(false);
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::io::Write as _;
128    use std::os::unix::fs::MetadataExt as _;
129    use tempfile::TempDir;
130
131    #[test]
132    fn test_persist_if_changed() {
133        for size in [1, 20, CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1, CHUNK_SIZE * 10] {
134            let dir = TempDir::new().unwrap();
135            let path = dir.path().join("file");
136
137            let mut body = (0..std::u8::MAX).cycle().take(size).collect::<Vec<_>>();
138
139            // We should persist the file if it doesn't already exist.
140            let mut tmp1 = NamedTempFile::new_in(dir.path()).unwrap();
141            tmp1.write_all(&body).unwrap();
142            tmp1.persist_if_changed(&path).unwrap();
143            assert_eq!(std::fs::read(&path).unwrap(), body);
144
145            let metadata1 = std::fs::metadata(&path).unwrap();
146
147            // We should not persist if the contents didn't change.
148            let mut tmp2 = NamedTempFile::new_in(dir.path()).unwrap();
149            tmp2.write_all(&body).unwrap();
150            tmp2.persist_if_changed(&path).unwrap();
151            assert_eq!(std::fs::read(&path).unwrap(), body);
152
153            // inode shouldn't change if the content doesn't change.
154            let metadata2 = std::fs::metadata(&path).unwrap();
155            assert_eq!(metadata1.dev(), metadata2.dev());
156            assert_eq!(metadata1.ino(), metadata2.ino());
157
158            // We should persist if the contents changed (even though the length
159            // did not).
160            *body.last_mut().unwrap() = 1;
161
162            let mut tmp3 = NamedTempFile::new_in(dir.path()).unwrap();
163            tmp3.write_all(&body).unwrap();
164            tmp3.persist_if_changed(&path).unwrap();
165            assert_eq!(std::fs::read(&path).unwrap(), body);
166
167            let metadata3 = std::fs::metadata(&path).unwrap();
168            assert_eq!(metadata1.dev(), metadata3.dev());
169            assert_ne!(metadata1.ino(), metadata3.ino());
170
171            // We should persist if the contents changed length.
172            body.push(2);
173
174            let mut tmp4 = NamedTempFile::new_in(dir.path()).unwrap();
175            tmp4.write_all(&body).unwrap();
176            tmp4.persist_if_changed(&path).unwrap();
177            assert_eq!(std::fs::read(&path).unwrap(), body);
178
179            let metadata4 = std::fs::metadata(&path).unwrap();
180            assert_eq!(metadata3.dev(), metadata4.dev());
181            assert_ne!(metadata3.ino(), metadata4.ino());
182        }
183    }
184
185    #[test]
186    fn test_files_have_same_content_same_contents() {
187        let dir = TempDir::new().unwrap();
188        let path1 = dir.path().join("file1");
189        let path2 = dir.path().join("file2");
190
191        std::fs::write(&path1, b"hello world").unwrap();
192        let mut file1 = File::open(&path1).unwrap();
193
194        std::fs::write(&path2, b"hello world").unwrap();
195        let mut file2 = File::open(&path2).unwrap();
196
197        assert!(files_have_same_content(&mut file1, &mut file2).unwrap());
198    }
199
200    #[test]
201    fn test_files_have_same_content_different_contents() {
202        let dir = TempDir::new().unwrap();
203        let path1 = dir.path().join("file1");
204        let path2 = dir.path().join("file2");
205
206        std::fs::write(&path1, b"hello world").unwrap();
207        let mut file1 = File::open(&path1).unwrap();
208
209        std::fs::write(&path2, b"jello world").unwrap();
210        let mut file2 = File::open(&path2).unwrap();
211
212        assert!(!files_have_same_content(&mut file1, &mut file2).unwrap());
213    }
214
215    #[test]
216    fn test_files_have_same_content_truncated() {
217        let dir = TempDir::new().unwrap();
218        let path1 = dir.path().join("file1");
219        let path2 = dir.path().join("file2");
220
221        std::fs::write(&path1, b"hello world").unwrap();
222        let mut file1 = File::open(&path1).unwrap();
223
224        std::fs::write(&path2, b"jello").unwrap();
225        let mut file2 = File::open(&path2).unwrap();
226
227        assert!(!files_after_checking_length_have_same_content(&mut file1, &mut file2).unwrap());
228    }
229
230    #[test]
231    fn test_files_have_same_content_too_long() {
232        let dir = TempDir::new().unwrap();
233        let path1 = dir.path().join("file1");
234        let path2 = dir.path().join("file2");
235
236        std::fs::write(&path1, b"hello world").unwrap();
237        let mut file1 = File::open(&path1).unwrap();
238
239        std::fs::write(&path2, b"hello world jello").unwrap();
240        let mut file2 = File::open(&path2).unwrap();
241
242        assert!(!files_after_checking_length_have_same_content(&mut file1, &mut file2).unwrap());
243    }
244}