Skip to content

Commit a5148fd

Browse files
lcianclaude
andcommitted
fix(snapshots): Fix TOCTOU by reading each file once at upload time
Hash and upload contents now come from the same fs::read, eliminating a race where a file could change between the hash computation and upload. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 688de63 commit a5148fd

1 file changed

Lines changed: 30 additions & 38 deletions

File tree

src/commands/build/snapshots.rs

Lines changed: 30 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -72,29 +72,10 @@ struct ImageMetadata {
7272
struct ImageInfo {
7373
path: std::path::PathBuf,
7474
relative_path: String,
75-
hash: String,
7675
width: u32,
7776
height: u32,
7877
}
7978

80-
impl ImageInfo {
81-
fn into_manifest_entry(self) -> (String, ImageMetadata) {
82-
let image_file_name = Path::new(&self.relative_path)
83-
.file_name()
84-
.unwrap_or_default()
85-
.to_string_lossy()
86-
.into_owned();
87-
(
88-
self.hash,
89-
ImageMetadata {
90-
image_file_name,
91-
width: self.width,
92-
height: self.height,
93-
},
94-
)
95-
}
96-
}
97-
9879
pub fn execute(matches: &ArgMatches) -> Result<()> {
9980
eprintln!("{EXPERIMENTAL_WARNING}");
10081

@@ -138,15 +119,12 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
138119
style(images.len()).yellow(),
139120
if images.len() == 1 { "file" } else { "files" }
140121
);
141-
upload_images(&images, &org, &project)?;
122+
let manifest_entries = upload_images(images, &org, &project)?;
142123

143124
// Build manifest from discovered images
144125
let manifest = SnapshotsManifest {
145126
app_id: app_id.clone(),
146-
images: images
147-
.into_iter()
148-
.map(ImageInfo::into_manifest_entry)
149-
.collect(),
127+
images: manifest_entries,
150128
};
151129

152130
// POST manifest to API
@@ -192,12 +170,10 @@ fn collect_images(dir: &Path) -> Result<Vec<ImageInfo>> {
192170
}
193171
/// Builds [`ImageInfo`] for a discovered image path during snapshot collection.
194172
///
195-
/// Returns `Ok(Some(ImageInfo))` when the file is readable and its dimensions can
196-
/// be parsed, `Ok(None)` when the file should be skipped (currently when image
197-
/// dimensions cannot be determined), and `Err` for hard failures such as I/O
198-
/// errors reading the file.
173+
/// Returns `Ok(Some(ImageInfo))` when the image dimensions can be parsed,
174+
/// `Ok(None)` when the file should be skipped (e.g. when dimensions cannot be
175+
/// determined), and `Err` for hard failures.
199176
fn collect_image_info(dir: &Path, path: &Path) -> Result<Option<ImageInfo>> {
200-
let contents = fs::read(path).with_context(|| format!("Failed to read: {}", path.display()))?;
201177
let (width, height) = match imagesize::size(path) {
202178
Ok(dims) => (dims.width as u32, dims.height as u32),
203179
Err(err) => {
@@ -211,11 +187,9 @@ fn collect_image_info(dir: &Path, path: &Path) -> Result<Option<ImageInfo>> {
211187
.to_string_lossy()
212188
.to_string();
213189

214-
let hash = compute_sha256_hash(&contents);
215190
Ok(Some(ImageInfo {
216191
path: path.to_path_buf(),
217192
relative_path: relative,
218-
hash,
219193
width,
220194
height,
221195
}))
@@ -242,7 +216,11 @@ fn is_image_file(path: &Path) -> bool {
242216
.unwrap_or(false)
243217
}
244218

245-
fn upload_images(images: &[ImageInfo], org: &str, project: &str) -> Result<()> {
219+
fn upload_images(
220+
images: Vec<ImageInfo>,
221+
org: &str,
222+
project: &str,
223+
) -> Result<HashMap<String, ImageMetadata>> {
246224
let api = Api::current();
247225
let authenticated_api = api.authenticated()?;
248226
let options = authenticated_api.fetch_snapshots_upload_options(org, project)?;
@@ -270,21 +248,35 @@ fn upload_images(images: &[ImageInfo], org: &str, project: &str) -> Result<()> {
270248
.context("Failed to create tokio runtime")?;
271249

272250
let mut many_builder = session.many();
251+
let mut manifest_entries = HashMap::new();
252+
let image_count = images.len();
273253

274254
for image in images {
275255
debug!("Processing image: {}", image.path.display());
276256

277257
let contents = fs::read(&image.path)
278258
.with_context(|| format!("Failed to read image: {}", image.path.display()))?;
259+
let hash = compute_sha256_hash(&contents);
279260

280-
info!("Queueing {} as {}", image.path.display(), image.hash);
261+
info!("Queueing {} as {}", image.relative_path, hash);
281262

282263
many_builder = many_builder.push(
283264
session
284265
.put(contents)
285-
.key(&image.hash)
266+
.key(&hash)
286267
.expiration_policy(expiration),
287268
);
269+
270+
let image_file_name = Path::new(&image.relative_path)
271+
.file_name()
272+
.unwrap_or_default()
273+
.to_string_lossy()
274+
.into_owned();
275+
manifest_entries.insert(hash, ImageMetadata {
276+
image_file_name,
277+
width: image.width,
278+
height: image.height,
279+
});
288280
}
289281

290282
let upload = runtime
@@ -296,10 +288,10 @@ fn upload_images(images: &[ImageInfo], org: &str, project: &str) -> Result<()> {
296288
println!(
297289
"{} Uploaded {} image {}",
298290
style(">").dim(),
299-
style(images.len()).yellow(),
300-
if images.len() == 1 { "file" } else { "files" }
291+
style(image_count).yellow(),
292+
if image_count == 1 { "file" } else { "files" }
301293
);
302-
Ok(())
294+
Ok(manifest_entries)
303295
}
304296
Err(errors) => {
305297
eprintln!("There were errors uploading images:");
@@ -309,7 +301,7 @@ fn upload_images(images: &[ImageInfo], org: &str, project: &str) -> Result<()> {
309301
anyhow::bail!(
310302
"Failed to upload {} out of {} images",
311303
errors.len(),
312-
images.len()
304+
image_count
313305
)
314306
}
315307
}

0 commit comments

Comments
 (0)