From ab3903dceae1a8d9a54cee613b5465604b7f694d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Mon, 27 Apr 2026 02:15:29 -0600 Subject: [PATCH] fix: resolve -M/-C/-A sentinel collision for direct mock_file_check callbacks FALLBACK_TO_REAL_OP (-1) collides with the legitimate NV value -1.0 (file modified exactly 1 day in the future). PR #87 fixed the mock_all_from_stat path via scalar ref wrapping, but direct mock_file_check callbacks still had the collision. Fix: make FALLBACK_TO_REAL_OP a dualvar (numeric -1, string "__OFC_FALLBACK__"). The Perl-layer _check() now uses string comparison for NV ops, distinguishing real -1.0 from the sentinel. Numeric comparisons (== -1) remain backward compatible. Closes #88 Co-Authored-By: Claude Opus 4.6 --- FileCheck.xs | 14 +++++++- lib/Overload/FileCheck.pm | 6 ++-- t/01_boot.t | 2 +- t/nv-sentinel-collision.t | 68 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/FileCheck.xs b/FileCheck.xs index 8d893fc..be87db8 100644 --- a/FileCheck.xs +++ b/FileCheck.xs @@ -562,7 +562,19 @@ BOOT: newCONSTSUB(stash, "CHECK_IS_TRUE", &PL_sv_yes ); /* could use newSViv(1) or &PL_sv_yes */ newCONSTSUB(stash, "CHECK_IS_FALSE", &PL_sv_no ); /* could use newSViv(0) or &PL_sv_no */ newCONSTSUB(stash, "CHECK_IS_NULL", &PL_sv_undef ); - newCONSTSUB(stash, "FALLBACK_TO_REAL_OP", newSVnv(-1) ); + /* FALLBACK_TO_REAL_OP is a dualvar: numeric -1, string + * "__OFC_FALLBACK__". The string form lets the Perl-layer + * _check() distinguish a real -1.0 NV result (e.g. -M on a + * file 1 day in the future) from the sentinel. Numeric + * comparisons (== -1) keep working for backward compat. */ + { + SV *fb = newSVnv(-1); + sv_setpv(fb, "__OFC_FALLBACK__"); + SvNOK_on(fb); + SvIOK_on(fb); + SvIV_set(fb, -1); + newCONSTSUB(stash, "FALLBACK_TO_REAL_OP", fb); + } /* provide constants to add entry in a fake stat array */ diff --git a/lib/Overload/FileCheck.pm b/lib/Overload/FileCheck.pm index 5034976..c31e4b1 100644 --- a/lib/Overload/FileCheck.pm +++ b/lib/Overload/FileCheck.pm @@ -511,8 +511,10 @@ sub _check { if ( ref $out eq 'SCALAR' ) { return ( CHECK_IS_TRUE, $$out ); } - # Bare scalar: from direct mock_file_check callbacks - if ( !ref $out && $out == FALLBACK_TO_REAL_OP ) { + # Bare scalar: from direct mock_file_check callbacks. + # Use string comparison to detect the dualvar sentinel — + # numeric == would collide with a real -1.0 value. + if ( !ref $out && "$out" eq '__OFC_FALLBACK__' ) { return (FALLBACK_TO_REAL_OP); } return ( CHECK_IS_TRUE, $out ); diff --git a/t/01_boot.t b/t/01_boot.t index b85114b..ebc9b9a 100644 --- a/t/01_boot.t +++ b/t/01_boot.t @@ -20,7 +20,7 @@ is Overload::FileCheck::_loaded(), 1, '_loaded'; is int Overload::FileCheck::CHECK_IS_TRUE(), 1, "CHECK_IS_TRUE"; is int Overload::FileCheck::CHECK_IS_FALSE(), 0, "CHECK_IS_FALSE"; -is Overload::FileCheck::FALLBACK_TO_REAL_OP(), -1, "FALLBACK_TO_REAL_OP"; +ok Overload::FileCheck::FALLBACK_TO_REAL_OP() == -1, "FALLBACK_TO_REAL_OP == -1"; my @ops = qw{ OP_FTRREAD diff --git a/t/nv-sentinel-collision.t b/t/nv-sentinel-collision.t index 95c1052..1a8c42b 100644 --- a/t/nv-sentinel-collision.t +++ b/t/nv-sentinel-collision.t @@ -136,4 +136,72 @@ subtest 'mock_all_from_stat: other NV values pass through correctly' => sub { unmock_stat(); }; +subtest 'direct mock_file_check: -M returns -1.0 without sentinel collision' => sub { + mock_file_check( '-M' => sub { + my ($file) = @_; + return -1.0 if $file eq '/test/exactly_minus_one'; + return FALLBACK_TO_REAL_OP; + }); + + my $age = -M '/test/exactly_minus_one'; + ok( defined $age, '-M returns a defined value (not undef from fallback)' ); + ok( abs($age - (-1.0)) < 0.01, "-M returns -1.0 (got: $age)" ) + if defined $age; + + my $real = -M '/nonexistent/direct/mock/fallback'; + ok( !defined $real, 'FALLBACK_TO_REAL_OP still delegates to real OP' ); + + unmock_file_check('-M'); +}; + +subtest 'direct mock_file_check: -A returns -1.0 without sentinel collision' => sub { + mock_file_check( '-A' => sub { + my ($file) = @_; + return -1.0 if $file eq '/test/exactly_minus_one'; + return FALLBACK_TO_REAL_OP; + }); + + my $age = -A '/test/exactly_minus_one'; + ok( defined $age, '-A returns a defined value' ); + ok( abs($age - (-1.0)) < 0.01, "-A returns -1.0 (got: $age)" ) + if defined $age; + + unmock_file_check('-A'); +}; + +subtest 'direct mock_file_check: -C returns -1.0 without sentinel collision' => sub { + mock_file_check( '-C' => sub { + my ($file) = @_; + return -1.0 if $file eq '/test/exactly_minus_one'; + return FALLBACK_TO_REAL_OP; + }); + + my $age = -C '/test/exactly_minus_one'; + ok( defined $age, '-C returns a defined value' ); + ok( abs($age - (-1.0)) < 0.01, "-C returns -1.0 (got: $age)" ) + if defined $age; + + unmock_file_check('-C'); +}; + +subtest 'FALLBACK_TO_REAL_OP constant backward compat' => sub { + ok( FALLBACK_TO_REAL_OP == -1, 'numeric comparison still works' ); + ok( FALLBACK_TO_REAL_OP < 0, 'numeric ordering still works' ); +}; + +subtest 'direct mock_file_check: scalar ref workaround also works' => sub { + mock_file_check( '-M' => sub { + my ($file) = @_; + return \(-1.0) if $file eq '/test/ref_wrap'; + return FALLBACK_TO_REAL_OP; + }); + + my $age = -M '/test/ref_wrap'; + ok( defined $age, '-M returns defined for scalar ref wrapped -1.0' ); + ok( abs($age - (-1.0)) < 0.01, "-M returns -1.0 via scalar ref (got: $age)" ) + if defined $age; + + unmock_file_check('-M'); +}; + done_testing;