App-makefilepl2cpanfile

 view release on metacpan or  search on metacpan

t/edge_cases.t  view on Meta::CPAN

		App::makefilepl2cpanfile::generate(
			makefile     => "$mf",
			with_develop => 0,
		)
	} qr/Input\/output error.*simulated disk failure/,
		'slurp_utf8 I/O failure propagates transparently from generate()';
};

subtest 'upstream: File::HomeDir::my_home returns undef - falls back to defaults (BUG 3)' => sub {
	# Simulates a container or CI job that runs under a user with no home
	# directory set (e.g., nobody, or a UID with no passwd entry).
	# BUG 3 (fixed): before the fix, path(undef) croaked from Path::Tiny
	# with "positive-length parts" error, not a clean fallback to defaults.
	my $g_home = mock_scoped 'File::HomeDir::my_home' => sub { undef };
	my $mf     = make_mf($MF_SIMPLE);

	my $out;
	lives_ok {
		$out = App::makefilepl2cpanfile::generate(
			makefile     => "$mf",
			with_develop => 1,    # forces _load_develop_config call
		)
	} 'undef my_home does not crash (BUG 3 fixed - falls back to defaults)';

	like $out, qr/Perl::Critic/,
		'default develop tool present - fallback to %DEFAULT_DEVELOP works';
	like $out, qr/on 'develop' => sub/,
		'develop block was emitted using default tools';

	diag "Output:\n$out" if $ENV{TEST_VERBOSE};
};

subtest 'upstream: YAML::Tiny simulated timeout - croak includes path and message' => sub {
	# Simulates a YAML parsing backend that fails mid-operation (e.g. a remote
	# config service returning an error, or a corrupt/truncated YAML file).
	# The croak message must include both the config file path and the upstream
	# error text so operators can diagnose the failure.
	my $dir = tempdir(CLEANUP => 1);
	path($dir)->child('.config')->mkpath;
	path($dir)->child('.config', 'makefilepl2cpanfile.yml')->spew_utf8(
		"develop:\n  Perl::Critic: 0\n"    # valid YAML on disk
	);

	my $g_home = mock_scoped 'File::HomeDir::my_home' => sub { $dir };
	my $g_yaml = mock_scoped(
		'YAML::Tiny::read'   => sub { undef },
		'YAML::Tiny::errstr' => sub { 'connection timed out after 30s' },
	);

	my $mf = make_mf($MF_SIMPLE);

	throws_ok {
		App::makefilepl2cpanfile::generate(
			makefile     => "$mf",
			with_develop => 1,
		)
	} qr/Failed to parse .+ connection timed out/s,
		'YAML upstream failure croaks with path and error message';
};

subtest 'upstream: config path cannot be examined (stat() failure) - croaks' => sub {
	# Simulates a filesystem that denies access after the home directory was
	# resolved (the same class of failure as an NFS mount going stale).  The
	# config may exist, so silently falling back to the defaults would change
	# the output without explanation; the error must reach the caller.  A
	# real EACCES is produced by making ~/.config unsearchable.
	SKIP: {
		my $home = path(tempdir(CLEANUP => 1));
		skip why_not('search', $home), 1 unless can_revoke_search($home);
		my $cfg_dir = $home->child('.config');
		$cfg_dir->mkpath;
		$cfg_dir->child('makefilepl2cpanfile.yml')->spew_utf8("develop:\n  X: 1\n");
		my $g  = mock_scoped 'File::HomeDir::my_home' => sub { "$home" };
		my $mf = make_mf($MF_SIMPLE);
		my $msg_eacces = do { local $! = POSIX::EACCES(); "$!" };

		with_revoked(search => "$cfg_dir", sub {
			throws_ok {
				App::makefilepl2cpanfile::generate(makefile => "$mf", with_develop => 1)
			} qr/\AFailed to parse \Q$cfg_dir\E\/makefilepl2cpanfile\.yml: \Q$msg_eacces\E at /,
				'stat() failure on the config path croaks with the errno text';
		});
	}
};

# -----------------------------------------------------------------------
# SECTION 8: Mid-flight Hardware/OS Failure Simulation
#
# Injects POSIX errno-flavoured failures into the I/O wrapper layer
# (Path::Tiny, YAML::Tiny) AFTER the readability guards have already
# passed.  This simulates hardware faults, TOCTOU races, interrupted
# syscalls, and truncated reads that cannot be detected by file-test
# operators before the call.
#
# Three invariants are verified throughout:
#   1. The error propagates unmasked from generate().
#   2. No global Perl state ($@, Readonly constants) is corrupted.
#   3. A subsequent call with working I/O produces correct output.
# -----------------------------------------------------------------------

# OS-canonical errno strings derived from Perl's $! layer rather than
# POSIX::strerror() to stay locale-consistent (see locales.t guidance).
Readonly my $MSG_EIO    => do { local $! = EIO;    "$!" };
Readonly my $MSG_ENOSPC => do { local $! = ENOSPC; "$!" };
Readonly my $MSG_EINTR  => do { local $! = EINTR;  "$!" };
Readonly my $MSG_ENOENT => do { local $! = ENOENT; "$!" };
Readonly my $MSG_ENOMEM => do { local $! = ENOMEM; "$!" };

subtest 'io-failure-read: EIO (hardware fault) mid-slurp - propagates with POSIX errno string' => sub {
	# A hardware read error (disk controller failure, bit-rot) that occurs
	# AFTER the -f / -r guard has already confirmed the file's existence.
	# Strategy: file is real (passes guard); slurp_utf8 is mocked to die
	# with local $! = EIO, matching what the kernel would set.
	my $g_home  = empty_home();
	my $mf      = make_mf($MF_SIMPLE);
	my $g_slurp = mock_scoped 'Path::Tiny::slurp_utf8' => sub {
		local $! = EIO;
		die "read: $!\n";
	};

	throws_ok {
		App::makefilepl2cpanfile::generate(makefile => "$mf", with_develop => 0)
	} qr/\Q$MSG_EIO\E/,
		"EIO mid-slurp propagates with canonical '$MSG_EIO' string";
};

subtest 'io-failure-read: EINTR (interrupted syscall) mid-slurp - propagates' => sub {
	# An async signal (SIGALRM, SIGTERM) arrived while sysread() was blocked.
	# The kernel set errno = EINTR.  Path::Tiny does not automatically retry;
	# the exception propagates to generate()'s caller.
	my $g_home  = empty_home();
	my $mf      = make_mf($MF_SIMPLE);
	my $g_slurp = mock_scoped 'Path::Tiny::slurp_utf8' => sub {
		local $! = EINTR;
		die "sysread: $!\n";
	};

	throws_ok {
		App::makefilepl2cpanfile::generate(makefile => "$mf", with_develop => 0)
	} qr/\Q$MSG_EINTR\E/,
		"EINTR mid-slurp propagates with canonical '$MSG_EINTR' string";
};

subtest 'io-failure-read: ENOENT (TOCTOU race) - file removed between guard and slurp' => sub {
	# Race window: another process deletes Makefile.PL after the -f/-r check
	# passes (TOCTOU).  slurp_utf8 then fails with ENOENT.  The error must
	# propagate clearly and not be confused with the Cannot-read guard croak.
	my $g_home  = empty_home();
	my $mf      = make_mf($MF_SIMPLE);
	my $g_slurp = mock_scoped 'Path::Tiny::slurp_utf8' => sub {
		local $! = ENOENT;
		die "open: $!\n";
	};

	throws_ok {
		App::makefilepl2cpanfile::generate(makefile => "$mf", with_develop => 0)
	} qr/\Q$MSG_ENOENT\E/,
		"ENOENT from TOCTOU race propagates with canonical '$MSG_ENOENT' string";
};

subtest 'io-failure-read: ENOMEM (OOM) during Makefile.PL buffer allocation - propagates' => sub {
	# The kernel cannot allocate the page-cache buffer for the file content.
	# Possible under strict cgroup memory limits in containers.
	my $g_home  = empty_home();
	my $mf      = make_mf($MF_SIMPLE);
	my $g_slurp = mock_scoped 'Path::Tiny::slurp_utf8' => sub {
		local $! = ENOMEM;
		die "mmap: $!\n";
	};

	throws_ok {
		App::makefilepl2cpanfile::generate(makefile => "$mf", with_develop => 0)
	} qr/\Q$MSG_ENOMEM\E/,
		"ENOMEM during buffer alloc propagates with canonical '$MSG_ENOMEM' string";
};

subtest 'io-failure-read: unexpected EOF - slurp_utf8 returns truncated Makefile.PL' => sub {
	# Simulate a file whose size shrank between stat() and read() (a log
	# rotator truncated the wrong file, or a network FS returned a stale
	# inode size).  The truncated string ends mid-PREREQ_PM (no closing
	# brace), so generate() must return a valid header-only cpanfile, not
	# a partial or malformed entry.
	my $g_home  = empty_home();
	my $mf      = make_mf($MF_SIMPLE);
	my $g_slurp = mock_scoped 'Path::Tiny::slurp_utf8' => sub {
		return "WriteMakefile(PREREQ_PM => { \x27Carp\x27 =>";    # truncated mid-value
	};

	my $out;
	lives_ok {
		$out = App::makefilepl2cpanfile::generate(
			makefile     => "$mf",
			with_develop => 0,
		)
	} 'truncated Makefile.PL (unexpected EOF) does not crash generate()';

	like   $out, qr/# Generated from/, 'header present in output for truncated input';
	unlike $out, qr/requires 'Carp'/,  'unclosed PREREQ_PM block produces no partial dep entry';

	diag "Output (truncated input):\n$out" if $ENV{TEST_VERBOSE};
};

subtest 'io-failure-config: ENOSPC during YAML config read - croak includes path and errno' => sub {
	# On copy-on-write (ZFS, BTRFS) and journaling (ext4) filesystems,
	# even read operations can fail with ENOSPC when the FS needs to
	# allocate metadata blocks.  The croak must include the config file
	# path so operators can identify the failing mount.
	my $dir = tempdir(CLEANUP => 1);
	path($dir)->child('.config')->mkpath;
	path($dir)->child('.config', 'makefilepl2cpanfile.yml')
		->spew_utf8("develop:\n  Perl::Critic: 0\n");

	my $g_home = mock_scoped 'File::HomeDir::my_home' => sub { $dir };
	my $g_yaml = mock_scoped(
		'YAML::Tiny::read'   => sub { return undef },
		'YAML::Tiny::errstr' => sub { "No space left on device (ENOSPC)" },
	);
	my $mf = make_mf($MF_SIMPLE);

	throws_ok {
		App::makefilepl2cpanfile::generate(makefile => "$mf", with_develop => 1)
	} qr/Failed to parse .+ No space left on device/,
		'ENOSPC on config FS propagates with file path and errno description';
};

subtest 'io-failure-config: EIO during YAML config read - croak includes file path' => sub {
	# Simulates a hardware error on the filesystem holding the user config
	# (e.g., USB drive physically removed while the config was being read).
	my $dir = tempdir(CLEANUP => 1);
	path($dir)->child('.config')->mkpath;
	path($dir)->child('.config', 'makefilepl2cpanfile.yml')
		->spew_utf8("develop:\n  Perl::Critic: 0\n");

	my $g_home = mock_scoped 'File::HomeDir::my_home' => sub { $dir };
	my $g_yaml = mock_scoped(
		'YAML::Tiny::read'   => sub { return undef },
		'YAML::Tiny::errstr' => sub { "Input/output error (EIO)" },
	);



( run in 0.752 second using v1.01-cache-2.11-cpan-28bccf0f6e5 )