88//! background thread; **the handle must be kept alive** for the watcher
99//! to run.
1010
11- use std:: collections:: hash_map:: DefaultHasher ;
1211use std:: collections:: { HashMap , HashSet } ;
13- use std:: hash:: Hasher ;
1412use std:: path:: { Path , PathBuf } ;
1513use std:: sync:: Arc ;
1614use std:: time:: Duration ;
@@ -24,6 +22,15 @@ use notify_debouncer_mini::{
2422 new_debouncer_opt, Config as DebouncerConfig , DebounceEventResult , Debouncer ,
2523} ;
2624
25+ #[ path = "watch_hash.rs" ]
26+ mod hash;
27+
28+ #[ cfg( test) ]
29+ #[ path = "watch_hash_tests.rs" ]
30+ mod hash_tests;
31+
32+ use hash:: { hash_file, HASH_BUFFER_SIZE } ;
33+
2734/// Owns the watcher background thread. Drop to stop watching.
2835///
2936/// `notify-debouncer-mini` spawns its background thread inside the
@@ -146,6 +153,7 @@ where
146153 . collect ( ) ;
147154
148155 let mut content_hashes: HashMap < PathBuf , u64 > = HashMap :: new ( ) ;
156+ let mut hash_buffer = [ 0_u8 ; HASH_BUFFER_SIZE ] ;
149157 let retry_unchanged_when = cfg. retry_unchanged_when . clone ( ) ;
150158 let explicit_filter = explicit_files. clone ( ) ;
151159 let notify_config = notify:: Config :: default ( ) . with_follow_symlinks ( false ) ;
@@ -183,8 +191,14 @@ where
183191 let retry_unchanged = retry_unchanged_when
184192 . as_ref ( )
185193 . is_some_and ( |predicate| predicate ( ) ) ;
186- paths
187- . retain ( |path| should_forward_path ( & mut content_hashes, path, retry_unchanged) ) ;
194+ paths. retain ( |path| {
195+ should_forward_path (
196+ & mut content_hashes,
197+ path,
198+ retry_unchanged,
199+ & mut hash_buffer,
200+ )
201+ } ) ;
188202 if !paths. is_empty ( ) {
189203 on_event ( paths) ;
190204 }
@@ -311,19 +325,17 @@ pub fn default_ignore_paths() -> Vec<PathBuf> {
311325 ]
312326}
313327
314- /// Largest file the watcher will hash to detect a no-op change. Above this,
315- /// an event is always treated as a change — hashing a huge file on every event
316- /// would cost more than an occasional rebuild. Dev source files are tiny, so
317- /// this only guards pathological inputs.
318- const MAX_HASH_BYTES : u64 = 8 * 1024 * 1024 ;
319-
320328/// Whether `path`'s content changed since the previous event, updating `cache`.
321329///
322330/// A path that cannot be read as a regular file within the size cap (deleted,
323331/// a directory, a permissions error, or oversized) is treated as **changed** so
324332/// deletions still trigger a rebuild and large files are never silently skipped.
325- fn content_changed ( cache : & mut HashMap < PathBuf , u64 > , path : & Path ) -> bool {
326- match hash_file ( path) {
333+ fn content_changed (
334+ cache : & mut HashMap < PathBuf , u64 > ,
335+ path : & Path ,
336+ buffer : & mut [ u8 ; HASH_BUFFER_SIZE ] ,
337+ ) -> bool {
338+ match hash_file ( path, buffer) {
327339 Some ( hash) => match cache. insert ( path. to_path_buf ( ) , hash) {
328340 Some ( previous) => previous != hash,
329341 None => true ,
@@ -339,22 +351,9 @@ fn should_forward_path(
339351 cache : & mut HashMap < PathBuf , u64 > ,
340352 path : & Path ,
341353 retry_unchanged : bool ,
354+ buffer : & mut [ u8 ; HASH_BUFFER_SIZE ] ,
342355) -> bool {
343- content_changed ( cache, path) || retry_unchanged
344- }
345-
346- /// Hash the full contents of `path`, or `None` if it is not a readable regular
347- /// file within [`MAX_HASH_BYTES`]. Uses the standard hasher — collision
348- /// resistance is irrelevant here; we only need "did these bytes change".
349- fn hash_file ( path : & Path ) -> Option < u64 > {
350- let metadata = std:: fs:: metadata ( path) . ok ( ) ?;
351- if !metadata. is_file ( ) || metadata. len ( ) > MAX_HASH_BYTES {
352- return None ;
353- }
354- let bytes = std:: fs:: read ( path) . ok ( ) ?;
355- let mut hasher = DefaultHasher :: new ( ) ;
356- hasher. write ( & bytes) ;
357- Some ( hasher. finish ( ) )
356+ content_changed ( cache, path, buffer) || retry_unchanged
358357}
359358
360359#[ cfg( test) ]
@@ -489,25 +488,26 @@ mod tests {
489488 let file = dir. path ( ) . join ( "a.css" ) ;
490489 std:: fs:: write ( & file, "a { color: red; }" ) . unwrap ( ) ;
491490 let mut cache = HashMap :: new ( ) ;
491+ let mut buffer = [ 0_u8 ; HASH_BUFFER_SIZE ] ;
492492
493493 // First sighting → changed (nothing cached yet).
494- assert ! ( content_changed( & mut cache, & file) ) ;
494+ assert ! ( content_changed( & mut cache, & file, & mut buffer ) ) ;
495495 // Re-saving identical bytes (repeated Ctrl+S) → no change → no rebuild.
496- assert ! ( !content_changed( & mut cache, & file) ) ;
497- assert ! ( !content_changed( & mut cache, & file) ) ;
496+ assert ! ( !content_changed( & mut cache, & file, & mut buffer ) ) ;
497+ assert ! ( !content_changed( & mut cache, & file, & mut buffer ) ) ;
498498
499499 // A real edit → changed.
500500 std:: fs:: write ( & file, "a { color: blue; }" ) . unwrap ( ) ;
501- assert ! ( content_changed( & mut cache, & file) ) ;
501+ assert ! ( content_changed( & mut cache, & file, & mut buffer ) ) ;
502502 // Identical again → unchanged.
503- assert ! ( !content_changed( & mut cache, & file) ) ;
503+ assert ! ( !content_changed( & mut cache, & file, & mut buffer ) ) ;
504504
505505 // Deletion → changed, so a rebuild can clear stale output.
506506 std:: fs:: remove_file ( & file) . unwrap ( ) ;
507- assert ! ( content_changed( & mut cache, & file) ) ;
507+ assert ! ( content_changed( & mut cache, & file, & mut buffer ) ) ;
508508 // The cache forgot it, so a later recreation is a fresh change.
509509 std:: fs:: write ( & file, "a { color: blue; }" ) . unwrap ( ) ;
510- assert ! ( content_changed( & mut cache, & file) ) ;
510+ assert ! ( content_changed( & mut cache, & file, & mut buffer ) ) ;
511511 }
512512
513513 #[ test]
@@ -516,9 +516,88 @@ mod tests {
516516 let file = dir. path ( ) . join ( "a.css" ) ;
517517 std:: fs:: write ( & file, "a { color: red; }" ) . unwrap ( ) ;
518518 let mut cache = HashMap :: new ( ) ;
519+ let mut buffer = [ 0_u8 ; HASH_BUFFER_SIZE ] ;
520+
521+ assert ! ( should_forward_path( & mut cache, & file, false , & mut buffer) ) ;
522+ assert ! ( !should_forward_path( & mut cache, & file, false , & mut buffer) ) ;
523+ assert ! ( should_forward_path( & mut cache, & file, true , & mut buffer) ) ;
524+ assert ! ( !should_forward_path( & mut cache, & file, false , & mut buffer) ) ;
525+
526+ std:: fs:: write ( & file, "a { color: blue; }" ) . unwrap ( ) ;
527+ assert ! ( should_forward_path( & mut cache, & file, true , & mut buffer) ) ;
528+ assert ! ( !should_forward_path( & mut cache, & file, false , & mut buffer) ) ;
529+ }
530+
531+ #[ test]
532+ fn unhashable_paths_forget_cached_content ( ) -> std:: io:: Result < ( ) > {
533+ let dir = tempfile:: tempdir ( ) ?;
534+ let file = dir. path ( ) . join ( "a.css" ) ;
535+ let mut cache = HashMap :: new ( ) ;
536+ let mut buffer = [ 0_u8 ; HASH_BUFFER_SIZE ] ;
537+
538+ for replacement in [ "deleted" , "directory" , "oversized" ] {
539+ std:: fs:: write ( & file, "original" ) ?;
540+ assert ! ( content_changed( & mut cache, & file, & mut buffer) ) ;
541+ assert ! ( !content_changed( & mut cache, & file, & mut buffer) ) ;
542+
543+ std:: fs:: remove_file ( & file) ?;
544+ match replacement {
545+ "directory" => std:: fs:: create_dir ( & file) ?,
546+ "oversized" => std:: fs:: File :: create ( & file) ?. set_len ( 8 * 1024 * 1024 + 1 ) ?,
547+ _ => { }
548+ }
549+ for _ in 0 ..2 {
550+ assert ! ( content_changed( & mut cache, & file, & mut buffer) ) ;
551+ assert ! ( !cache. contains_key( & file) ) ;
552+ }
553+ match replacement {
554+ "directory" => std:: fs:: remove_dir ( & file) ?,
555+ "oversized" => std:: fs:: remove_file ( & file) ?,
556+ _ => { }
557+ }
558+ }
559+ std:: fs:: write ( & file, "original" ) ?;
560+ assert ! ( content_changed( & mut cache, & file, & mut buffer) ) ;
561+ Ok ( ( ) )
562+ }
519563
520- assert ! ( should_forward_path( & mut cache, & file, false ) ) ;
521- assert ! ( !should_forward_path( & mut cache, & file, false ) ) ;
522- assert ! ( should_forward_path( & mut cache, & file, true ) ) ;
564+ #[ test]
565+ fn hash_buffer_is_reused_across_files_and_event_batches ( ) -> std:: io:: Result < ( ) > {
566+ let dir = tempfile:: tempdir ( ) ?;
567+ let paths = [
568+ dir. path ( ) . join ( "large.css" ) ,
569+ dir. path ( ) . join ( "empty.css" ) ,
570+ dir. path ( ) . join ( "small.css" ) ,
571+ ] ;
572+ for ( path, size) in paths. iter ( ) . zip ( [ 3 * HASH_BUFFER_SIZE + 1 , 0 , 7 ] ) {
573+ std:: fs:: write ( path, vec ! [ b'x' ; size] ) ?;
574+ }
575+ let mut cache = HashMap :: new ( ) ;
576+ let mut buffer = [ 0_u8 ; HASH_BUFFER_SIZE ] ;
577+ for changed in [ true , false , false ] {
578+ for path in & paths {
579+ assert_eq ! ( content_changed( & mut cache, path, & mut buffer) , changed) ;
580+ }
581+ }
582+ Ok ( ( ) )
583+ }
584+
585+ #[ cfg( unix) ]
586+ #[ test]
587+ fn unreadable_symlink_loop_forgets_cached_content ( ) -> std:: io:: Result < ( ) > {
588+ let dir = tempfile:: tempdir ( ) ?;
589+ let file = dir. path ( ) . join ( "unreadable.css" ) ;
590+ std:: fs:: write ( & file, "original" ) ?;
591+ let mut cache = HashMap :: new ( ) ;
592+ let mut buffer = [ 0_u8 ; HASH_BUFFER_SIZE ] ;
593+ assert ! ( content_changed( & mut cache, & file, & mut buffer) ) ;
594+ std:: fs:: remove_file ( & file) ?;
595+ std:: os:: unix:: fs:: symlink ( "unreadable.css" , & file) ?;
596+ assert ! ( content_changed( & mut cache, & file, & mut buffer) ) ;
597+ assert ! ( !cache. contains_key( & file) ) ;
598+ std:: fs:: remove_file ( & file) ?;
599+ std:: fs:: write ( & file, "original" ) ?;
600+ assert ! ( content_changed( & mut cache, & file, & mut buffer) ) ;
601+ Ok ( ( ) )
523602 }
524603}
0 commit comments