NAME
Test::Permissions - Find out whether chmod can really take access away, so tests know when to skip
VERSION
0.001.0
SYNOPSIS
use Test::Most;
use File::Temp qw(tempdir);
use Test::Permissions qw(:revoke :guard :report);
my $dir = tempdir(CLEANUP => 1); # where the fixtures live
diag(permissions_report($dir)); # once, for CPAN Testers reports
SKIP: {
skip why_not('read', $dir), 1 unless can_revoke_read($dir);
with_revoked(read => "$dir/fixture", sub {
ok(!open(my $fh, '<', "$dir/fixture"), 'unreadable file is refused');
}); # the mode is restored, even if the block dies
}
SKIP: {
skip_unless_can_revoke('search', 1, $dir);
...
}
done_testing();
DESCRIPTION
Test suites often need to know whether chmod really takes access away, so they can skip tests that rely on an unreadable file or an unsearchable directory. The usual guess, skip ... if $> == 0, is wrong on:
Windows, where
chmodonly sets the read-only attribute;fakeroot, and containers where a non-root user holdsCAP_DAC_OVERRIDE;filesystems that ignore mode bits (FAT, some SMB/NFS/FUSE mounts, Cygwin
noaclmounts);root in a user namespace, where root may not be able to bypass modes;
root on an NFS export with
root_squash, where root is treated as "nobody".
Test::Permissions does not guess. It tries the operation on a scratch file in the directory you care about, and reports what actually happened. Results are cached per process.
Seven kinds of access can be probed:
Kind Question Restricted mode
read can a file be made unreadable? file 0
write can a file be made unwritable? file 0400
create can a directory be made to refuse new files? directory 0500
search can a directory be made unsearchable (stat of a directory 0
file inside it fails)?
exec can a script be made unrunnable? file 0600
delete can a directory be made to keep its files? directory 0500
sticky does the sticky bit stop one user deleting directory 01777
another user's file?
What to expect (your tests must not rely on these; that is the point):
Environment read write create search exec delete sticky
Linux/BSD/macOS, normal user 1 1 1 1 1 1 0 (a)
Unix root, or CAP_DAC_OVERRIDE 0 0 0 0 1(b) 0 1
fakeroot 0 0 0 0 1(b) 0 0
Windows (NTFS) 0 1 0 (c) 0 0 0 (c) 0
FAT or a mount that ignores modes 0 0 0 0 0 0 0
a noexec mount . . . . 0 . .
(a) the sticky probe must act as two users, so it needs root
(b) even root cannot run a file that has no execute bit at all
(c) Windows ignores the read-only attribute on directories
Which directory to probe
The answer depends on the filesystem, so pass the directory your fixtures live in (usually your own tempdir). If you pass nothing, the probe runs in File::Spec->tmpdir, which may be on a different filesystem from your fixtures (a tmpfs, for example) and give a different answer.
How a probe works
Each probe creates a fresh directory P inside the target directory and then:
- 1. Setup: creates the scratch objects with explicit modes, so the answer does not depend on your
umask. - 2. Baseline: does the operation while it is allowed. If that fails, the filesystem cannot tell us anything, and the answer is 0.
- 3. Restrict:
chmods to the restricted mode and checks, withstat, that the owner's permission bits (forsticky: all the mode bits) really changed. If not (this is what happens on Windows), the answer is 0. - 4. Attempt: does the operation again. It must fail with
EACCESorEPERMfor the answer to be 1. - 5. Restore and clean up: always, even if an earlier step failed. Nothing is left in the target directory.
A probe never dies and never warns: anything that goes wrong becomes an answer of 0, and "why_not(kind, dir)" tells you why. Only mistakes in the call itself (an unknown kind, a directory that does not exist) croak.
Paths with non-ASCII characters
Directory and file names are used exactly as perl's own file functions use them: as byte strings. On Unix, pass the same (usually UTF-8 encoded) bytes you would pass to open. On Windows, perl's file functions use the ANSI code page, so a name with characters outside it cannot be found, and the call croaks with error_not_a_directory or error_no_such_path. Names in messages are shown as they are, except that control and bidirectional-override characters are escaped.
Taint mode
The functions work under perl -T. The directory you pass is checked with -d, and its canonical path is then untainted, because the probe only creates its own scratch directory inside it. with_revoked likewise untaints the path you give it, because changing its mode is what you asked for. The exec probe runs its script with PATH, IFS, CDPATH, ENV and BASH_ENV removed from the environment.
SUBROUTINES/METHODS
Nothing is exported by default. Import what you need by name, or use a tag:
:revoke can_revoke_* , can_revoke, why_not, skip_unless_can_revoke
:acl acl_denies
:guard with_revoked
:report permissions_report
:all all of these, plus clear_cache, set_cache_scope, set_messages
Every function that takes dir accepts it in any of these forms:
f() # dir is File::Spec->tmpdir
f($dir)
f(dir => $dir)
f({ dir => $dir })
An object that stringifies (such as a Path::Tiny object) is accepted wherever a directory or path name is.
can_revoke_read(dir)
PURPOSE
Find out whether a file with mode 0 refuses open '<': that is, whether chmod can make a file unreadable in dir. The same as can_revoke('read', $dir).
ARGUMENTS
dir- optional. The directory to probe in. It must exist and be a directory. Default:File::Spec->tmpdir.
RETURNS
1 if chmod can take that access away in dir, otherwise 0. Never undef.
SIDE EFFECTS
The first call for a kind and directory creates and removes a probe directory inside
dir. Later calls use the cache and do not touch the filesystem.Never dies or warns because of what it finds; see "FAILURE POLICY".
Does not change the caller's
$@,$!orumask.
USAGE EXAMPLE
SKIP: {
skip 'chmod cannot make a file unreadable here', 1
unless Test::Permissions::can_revoke_read($dir);
...
}
API SPECIFICATION
Input
{
dir => {
type => 'string',
optional => 1,
min => 1,
position => 0,
},
}
Output
{ type => 'boolean' }
MESSAGES
Errors (the call dies):
'$dir' is not a directory (error_not_a_directory)
dir does not exist, or is not a directory.
What to do: pass the directory your fixtures live in.
Too many arguments: expected at most 1, got N (error_too_many_arguments)
More than one positional argument was given.
What to do: pass only the directory.
(an error from Params::Validate::Strict or Params::Get)
dir is not a string (for example an array reference), is empty,
or the named form has an unknown key.
The reason for a 0 answer is available from "why_not(kind, dir)", which lists every reason_* message.
can_revoke_write(dir)
PURPOSE
Find out whether a file with mode 0400 refuses open '>>': that is, whether chmod can make a file unwritable in dir. The same as can_revoke('write', $dir).
ARGUMENTS
dir- optional. As for "can_revoke_read(dir)".
RETURNS
1 or 0, never undef.
SIDE EFFECTS
As for "can_revoke_read(dir)".
USAGE EXAMPLE
SKIP: {
skip 'chmod cannot make a file read-only here', 1
unless Test::Permissions::can_revoke_write($dir);
chmod 0400, $file;
ok(!open(my $fh, '>>', $file), 'read-only file is refused');
chmod 0600, $file;
}
API SPECIFICATION
Input
{
dir => {
type => 'string',
optional => 1,
min => 1,
position => 0,
},
}
Output
{ type => 'boolean' }
MESSAGES
The same as "can_revoke_read(dir)".
can_revoke_create(dir)
PURPOSE
Find out whether a directory with mode 0500 refuses open '>' of a new file in it: that is, whether chmod can stop files being created in a directory in dir. The same as can_revoke('create', $dir).
Note: in App-makefilepl2cpanfile's private copy of this module, this question was called can_revoke_write. "can_revoke_write(dir)" now asks about a file's own write bit.
On Windows the answer is 0 with reason_not_enforced: Windows ignores the read-only attribute on directories.
ARGUMENTS
dir- optional. As for "can_revoke_read(dir)".
RETURNS
1 or 0, never undef.
SIDE EFFECTS
As for "can_revoke_read(dir)".
USAGE EXAMPLE
SKIP: {
skip Test::Permissions::why_not('create', $dir), 1
unless Test::Permissions::can_revoke_create($dir);
chmod 0500, $outdir;
ok(!eval { write_report($outdir) }, 'cannot write the report');
chmod 0700, $outdir;
}
API SPECIFICATION
Input
{
dir => {
type => 'string',
optional => 1,
min => 1,
position => 0,
},
}
Output
{ type => 'boolean' }
MESSAGES
The same as "can_revoke_read(dir)".
can_revoke_search(dir)
PURPOSE
Find out whether a directory with mode 0 makes stat of a file inside it fail: that is, whether chmod can make a directory in dir unsearchable. The same as can_revoke('search', $dir).
ARGUMENTS
dir- optional. As for "can_revoke_read(dir)".
RETURNS
1 or 0, never undef.
SIDE EFFECTS
As for "can_revoke_read(dir)".
USAGE EXAMPLE
SKIP: {
skip 'chmod cannot hide a directory here', 1
unless Test::Permissions::can_revoke_search($dir);
chmod 0, $subdir;
ok(!-e "$subdir/file", 'file inside is hidden');
chmod 0700, $subdir;
}
API SPECIFICATION
Input
{
dir => {
type => 'string',
optional => 1,
min => 1,
position => 0,
},
}
Output
{ type => 'boolean' }
MESSAGES
The same as "can_revoke_read(dir)".
can_revoke_exec(dir)
PURPOSE
Find out whether a script with mode 0600 refuses to run: that is, whether chmod can take away the execute bit in dir. The probe runs a two-line /bin/sh script with system, so it needs /bin/sh, and a filesystem not mounted noexec. The same as can_revoke('exec', $dir).
Unlike the other kinds, the answer is usually 1 even for root: root can run a file only if at least one of its execute bits is set.
ARGUMENTS
dir- optional. As for "can_revoke_read(dir)".
RETURNS
1 or 0, never undef.
SIDE EFFECTS
As for "can_revoke_read(dir)"; also starts two short-lived processes.
USAGE EXAMPLE
SKIP: {
skip Test::Permissions::why_not('exec', $dir), 1
unless Test::Permissions::can_revoke_exec($dir);
chmod 0600, $hook;
ok(!run_hook($hook), 'a hook without the execute bit is not run');
}
API SPECIFICATION
Input
{
dir => {
type => 'string',
optional => 1,
min => 1,
position => 0,
},
}
Output
{ type => 'boolean' }
MESSAGES
The same as "can_revoke_read(dir)". On a noexec mount, or without /bin/sh, the reason is reason_baseline_failed.
can_revoke_delete(dir)
PURPOSE
Find out whether a directory with mode 0500 refuses unlink of a file in it: that is, whether chmod can stop files being deleted from a directory in dir. The same as can_revoke('delete', $dir).
ARGUMENTS
dir- optional. As for "can_revoke_read(dir)".
RETURNS
1 or 0, never undef.
SIDE EFFECTS
As for "can_revoke_read(dir)".
USAGE EXAMPLE
SKIP: {
skip Test::Permissions::why_not('delete', $dir), 1
unless Test::Permissions::can_revoke_delete($dir);
chmod 0500, $spool;
ok(!eval { purge($spool) }, 'purge reports the failure');
chmod 0700, $spool;
}
API SPECIFICATION
Input
{
dir => {
type => 'string',
optional => 1,
min => 1,
position => 0,
},
}
Output
{ type => 'boolean' }
MESSAGES
The same as "can_revoke_read(dir)".
can_revoke_sticky(dir)
PURPOSE
Find out whether the sticky bit works in dir: in a directory with mode 01777, can one user be stopped from deleting another user's file? The same as can_revoke('sticky', $dir).
This needs two users, so the probe only runs as root (real and effective uid 0), and not on Windows. It gives its file to uid 65534 and tries to delete it as uid 65533, by setting $> for the moment of the unlink. Anywhere else the answer is 0 with reason_needs_root.
ARGUMENTS
dir- optional. As for "can_revoke_read(dir)".
RETURNS
1 or 0, never undef.
SIDE EFFECTS
As for "can_revoke_read(dir)". While it runs, the process briefly changes its effective uid and its working directory; both are restored before it returns.
USAGE EXAMPLE
SKIP: {
skip Test::Permissions::why_not('sticky', $dir), 1
unless Test::Permissions::can_revoke_sticky($dir);
...
}
API SPECIFICATION
Input
{
dir => {
type => 'string',
optional => 1,
min => 1,
position => 0,
},
}
Output
{ type => 'boolean' }
MESSAGES
The same as "can_revoke_read(dir)". When not running as root, the reason is reason_needs_root.
can_revoke(kind, dir)
PURPOSE
The general form of the can_revoke_* functions: answer the question for the kind of access named by kind.
ARGUMENTS
kind- required. One ofread,write,create,search,exec,deleteorsticky.dir- optional. As for "can_revoke_read(dir)".
Positional (can_revoke('read', $dir)), named (can_revoke(kind => 'read', dir => $dir)) and hash reference (can_revoke({ kind => 'read' })) forms are all accepted.
RETURNS
1 or 0, never undef.
SIDE EFFECTS
As for "can_revoke_read(dir)".
USAGE EXAMPLE
for my $kind (qw(read search)) {
SKIP: {
skip "cannot revoke $kind access", 1
unless Test::Permissions::can_revoke($kind, $dir);
...
}
}
API SPECIFICATION
Input
{
kind => {
type => 'string',
memberof => [ 'read', 'write', 'create', 'search', 'exec', 'delete', 'sticky' ],
position => 0,
},
dir => {
type => 'string',
optional => 1,
min => 1,
position => 1,
},
}
Output
{ type => 'boolean' }
MESSAGES
Errors (the call dies):
Unknown access kind '$kind'; expected one of: read, write, create,
search, exec, delete, sticky (error_unknown_kind)
What to do: use one of the listed kinds.
'$dir' is not a directory (error_not_a_directory)
What to do: pass an existing directory.
Too many arguments: expected at most 2, got N (error_too_many_arguments)
(an error from Params::Validate::Strict or Params::Get)
kind is missing or not a string, dir is not a string or is empty,
or the named form has an unknown key.
why_not(kind, dir)
PURPOSE
Say why the answer for kind in dir is 0, in words suitable for a skip message.
ARGUMENTS
The same as "can_revoke(kind, dir)".
RETURNS
undef when the answer is 1. Otherwise a non-empty string: one of the reason_* messages below.
SIDE EFFECTS
Runs the probe if it has not run yet for this kind and directory, exactly as "can_revoke(kind, dir)" would, and caches the result.
USAGE EXAMPLE
SKIP: {
my $why = Test::Permissions::why_not('search', $dir);
skip $why, 2 if defined $why;
...
}
API SPECIFICATION
Input
{
kind => {
type => 'string',
memberof => [ 'read', 'write', 'create', 'search', 'exec', 'delete', 'sticky' ],
position => 0,
},
dir => {
type => 'string',
optional => 1,
min => 1,
position => 1,
},
}
Output
{ type => 'string', optional => 1 }
MESSAGES
Errors: the same as "can_revoke(kind, dir)".
Reasons (returned, never thrown or warned). $dir is the canonical path of the directory; $error is the system's error text, in the current locale (so a reason may mix these English texts, or your own from "set_messages(%overrides)", with another language).
chmod cannot revoke $kind access in '$dir' (running as root, or the
filesystem ignores permissions) (reason_not_enforced)
The operation still worked after chmod. You are root, hold
CAP_DAC_OVERRIDE, run under fakeroot, or the filesystem ignores
modes. On Windows, create and delete give this reason because
Windows ignores the read-only attribute on directories.
What to do: nothing; skip the test. To run it, run the suite as
an ordinary user on a filesystem that honours modes.
chmod did not set mode $wanted in '$dir' (got $got)
(reason_chmod_ignored)
chmod "worked" but the permission bits did not change. This is
Windows, or a FAT or noacl mount.
What to do: nothing; skip the test.
$kind access fails in '$dir' even when it is allowed: $error
(reason_baseline_failed)
The operation failed before any permission was removed, so the
probe learnt nothing. The filesystem may be read-only or broken;
for exec, it may be mounted noexec, or /bin/sh may be missing.
What to do: check the directory and the filesystem.
$kind access in '$dir' failed for a reason other than permissions: $error
(reason_other_error)
After chmod the operation failed, but not with EACCES or EPERM
(for example ENOSPC).
What to do: check the error; the directory may be full or odd.
Could not set up the $kind probe in '$dir': $error
(reason_setup_failed)
The probe could not create its scratch files, usually because you
cannot write to dir.
What to do: pass a directory you can write to.
The sticky probe in '$dir' must act as two users, which needs root
(not Windows) (reason_needs_root)
Only the sticky probe gives this reason.
What to do: nothing; run the suite as root to probe it.
$reason; also could not clean up '$probe_dir': $error
(reason_cleanup_failed)
Restoring the modes or removing the probe directory failed, so
the answer is 0 whatever the probe found. $reason is one of the
reasons above, or, if the probe itself succeeded, the text of
reason_probe_succeeded:
chmod revoked $kind access in '$dir' (reason_probe_succeeded)
Only ever seen as the first part of reason_cleanup_failed.
What to do: remove $probe_dir by hand; it is also removed when
the process exits.
Paths and error texts in reasons have control characters and Unicode direction-override characters replaced by \x{..} escapes.
skip_unless_can_revoke(kind, count, dir)
PURPOSE
Skip the rest of the enclosing SKIP: block, with the reason from "why_not(kind, dir)", when chmod cannot revoke kind access.
ARGUMENTS
kind- required. As for "can_revoke(kind, dir)".count- required. The number of tests in the block, as forTest::More::skip. A whole number, 1 or more.dir- optional. As for "can_revoke_read(dir)".
RETURNS
Nothing, when the answer is 1. When the answer is 0 it does not return: it records count skipped tests and leaves the enclosing SKIP: block, exactly as a direct skip call would.
SIDE EFFECTS
Runs the probe, as "can_revoke(kind, dir)" does.
When the answer is 0, records
countskipped tests. With Test::More (or anything else that loads Test::Builder) it callsTest::More::skip. In a Test2::V0 suite, which does not load Test::Builder, it records the skips through Test2::API instead.Must be called inside a
SKIP:block, likeTest::More::skip. Outside one, perl dies withLabel not found for "last SKIP".
USAGE EXAMPLE
SKIP: {
Test::Permissions::skip_unless_can_revoke('search', 2, $dir);
chmod 0, $subdir;
ok(!-e "$subdir/file", 'file hidden');
ok(!opendir(my $dh, $subdir), 'directory unreadable');
chmod 0700, $subdir;
}
API SPECIFICATION
Input
{
kind => {
type => 'string',
memberof => [ 'read', 'write', 'create', 'search', 'exec', 'delete', 'sticky' ],
position => 0,
},
count => {
type => 'integer',
min => 1,
position => 1,
},
dir => {
type => 'string',
optional => 1,
min => 1,
position => 2,
},
}
Output
{ type => 'void' }
Returns nothing (an empty list) when the answer is 1.
MESSAGES
Errors: the same as "can_revoke(kind, dir)", plus an error from Params::Validate::Strict when count is missing, not a whole number, or less than 1.
The skip message is one of the reasons listed under "why_not(kind, dir)".
acl_denies(kind, path)
PURPOSE
Find out whether something other than the mode bits - usually an access control list - denies you kind access to an existing path, even though its mode bits allow it. Use it to skip or explain a test that fails because of a fixture's ACL, not its mode.
It compares what the mode bits say (for your effective uid and groups) with what the system says, using access(2) (perl's -r, -w and -x under use filetest 'access'), which takes ACLs into account. It does not open, change or run path.
ARGUMENTS
kind- required.read,writeorexec(for a directory,execmeans search).path- required. An existing file or directory.
RETURNS
1 if the mode bits allow the access but the system denies it, otherwise 0 (including when the mode bits themselves deny it).
SIDE EFFECTS
None. It is not cached: each call looks at path again.
Besides ACLs, a read-only mount, an immutable flag or a mandatory access control policy (SELinux, AppArmor) can also make it return 1. On Windows, perl's file tests do not consult ACLs, so it returns 0.
USAGE EXAMPLE
SKIP: {
skip "an ACL denies reading $fixture", 1
if Test::Permissions::acl_denies(read => $fixture);
ok(load($fixture), 'fixture loads');
}
API SPECIFICATION
Input
{
kind => {
type => 'string',
memberof => [ 'read', 'write', 'exec' ],
position => 0,
},
path => {
type => 'string',
min => 1,
position => 1,
},
}
Output
{ type => 'boolean' }
MESSAGES
Errors (the call dies):
Unknown access kind '$kind'; expected one of: read, write, exec
(error_unknown_kind)
'$path' does not exist (error_no_such_path)
What to do: pass an existing file or directory.
Too many arguments: expected at most 2, got N (error_too_many_arguments)
(an error from Params::Validate::Strict or Params::Get)
with_revoked(kind, path, code)
PURPOSE
Take kind access away from path, run code, and put the mode back - always, even if code dies. It uses the same restricted modes as the probes, so a test written as
SKIP: {
skip_unless_can_revoke('read', 1, $dir);
with_revoked(read => $file, sub { ok(!load($file), 'refused') });
}
cannot leave an unreadable file behind for the next test, or for File::Temp's cleanup.
ARGUMENTS
kind- required.read(mode 0),write(0400) orexec(0600), which apply to a file; orcreate(0500),search(0) ordelete(0500), which apply to a directory.path- required. An existing file (for read, write, exec) or directory (for create, search, delete).code- required. A code reference, called with no arguments.
RETURNS
Whatever code returns, in the same (list, scalar or void) context. If code dies, the mode is restored and the exception is passed on unchanged.
SIDE EFFECTS
Changes the mode of
pathwhilecoderuns, then restores the mode it had before.Does not check that
chmodreally takes the access away: use "can_revoke(kind, dir)" (orskip_unless_can_revoke) first.Under taint mode,
pathis untainted; see "Taint mode".
USAGE EXAMPLE
my $content = Test::Permissions::with_revoked(search => $dir, sub {
return eval { read_config("$dir/app.conf") };
});
ok(!defined $content, 'config in an unsearchable directory is not read');
API SPECIFICATION
Input
{
kind => {
type => 'string',
memberof => [ 'read', 'write', 'create', 'search', 'exec', 'delete' ],
position => 0,
},
path => {
type => 'string',
min => 1,
position => 1,
},
code => {
type => 'coderef',
position => 2,
},
}
Output
Whatever code returns; it is not checked.
MESSAGES
Errors (the call dies):
Unknown access kind '$kind'; expected one of: read, write, create,
search, exec, delete (error_unknown_kind)
sticky is not accepted: it does not take the owner's access away.
'$path' does not exist (error_no_such_path)
'$path' is not a directory (error_not_a_directory)
create, search and delete apply to a directory.
'$path' is a directory; $kind access is revoked on a file
(error_not_a_file)
read, write and exec apply to a file.
Could not chmod '$path' to $mode: $error (error_chmod_failed)
code was not run.
What to do: check that you own path.
Could not restore mode $mode on '$path': $error
(error_restore_failed)
code ran (and any exception it threw is lost), but the old mode
could not be put back.
What to do: fix the mode by hand.
(an error from Params::Validate::Strict or Params::Get)
For example, code is not a code reference.
(anything code dies with, unchanged)
permissions_report(dir)
PURPOSE
Describe, in a few lines, what chmod can revoke in dir: every kind, with the reason for each 0. Print it once with diag (or note) so that a CPAN Testers report shows the environment the tests ran in.
ARGUMENTS
dir- optional. As for "can_revoke_read(dir)".
RETURNS
A string of lines, ending without a newline:
Test::Permissions 0.001.0 in '/tmp/abc' (effective uid 1000):
read: yes
...
sticky: no - The sticky probe in '/tmp/abc' must act as two users, ...
SIDE EFFECTS
Probes every kind that is not already cached for dir, as "can_revoke(kind, dir)" would.
USAGE EXAMPLE
diag(Test::Permissions::permissions_report($dir));
API SPECIFICATION
Input
{
dir => {
type => 'string',
optional => 1,
min => 1,
position => 0,
},
}
Output
{ type => 'string', min => 1 }
MESSAGES
Errors: the same as "can_revoke_read(dir)".
The lines come from the messages report_header (arguments: version, directory, effective uid), report_yes (kind) and report_no (kind, reason), which "set_messages(%overrides)" can translate.
clear_cache()
PURPOSE
Forget every answer, so the next call probes again. This is mainly for the module's own tests, and for a directory whose permissions or mount have changed since it was probed.
ARGUMENTS
None.
RETURNS
Nothing.
SIDE EFFECTS
Empties the cache.
USAGE EXAMPLE
Test::Permissions::clear_cache();
API SPECIFICATION
Input
{}
Output
{ type => 'void' }
MESSAGES
None.
set_cache_scope(scope)
PURPOSE
Choose how answers are shared between directories.
directory(the default) - each directory is probed on its own. This is always right, because ACLs, bind mounts and mount options can differ between directories on one device.device- directories on the same device (the samest_dev) share answers. A suite that makes a newtempdirfor every test can use this to probe once instead of once per directory, when it knows its directories are alike. The reason text names the first directory probed on the device.
ARGUMENTS
scope- required.directoryordevice.
RETURNS
Nothing.
SIDE EFFECTS
Applies to later calls. Answers already cached under the other scope stay in the cache but are not used.
USAGE EXAMPLE
Test::Permissions::set_cache_scope('device');
API SPECIFICATION
Input
{
scope => {
type => 'string',
memberof => [ 'directory', 'device' ],
position => 0,
},
}
Output
{ type => 'void' }
MESSAGES
Errors (the call dies): an error from Params::Validate::Strict when scope is missing or not one of the two names.
set_messages(%overrides)
PURPOSE
Replace message texts by key, for example to translate them.
ARGUMENTS
Pairs of message key and text, as a list or a hash reference. The keys are those listed under MESSAGES for each function (error_*, reason_* and report_*). Each text is a sprintf format taking the same arguments, in the same order, as the default text.
RETURNS
Nothing.
SIDE EFFECTS
Changes the texts for the rest of the process.
Reasons are worded when a probe runs, so answers already in the cache keep their old wording until "clear_cache()".
All pairs are checked before any is applied: if one is invalid, nothing changes.
A text with more or fewer
%sthan the default does not warn: missing arguments are empty and extra ones are ignored.
USAGE EXAMPLE
Test::Permissions::set_messages(
reason_not_enforced => q{chmod ne peut pas retirer l'acces %s dans '%s'},
);
API SPECIFICATION
Input
{
error_unknown_kind => { type => 'string', min => 1, optional => 1 },
error_not_a_directory => { type => 'string', min => 1, optional => 1 },
error_not_a_file => { type => 'string', min => 1, optional => 1 },
error_no_such_path => { type => 'string', min => 1, optional => 1 },
error_unknown_message => { type => 'string', min => 1, optional => 1 },
error_too_many_arguments => { type => 'string', min => 1, optional => 1 },
error_chmod_failed => { type => 'string', min => 1, optional => 1 },
error_restore_failed => { type => 'string', min => 1, optional => 1 },
reason_not_enforced => { type => 'string', min => 1, optional => 1 },
reason_chmod_ignored => { type => 'string', min => 1, optional => 1 },
reason_baseline_failed => { type => 'string', min => 1, optional => 1 },
reason_other_error => { type => 'string', min => 1, optional => 1 },
reason_setup_failed => { type => 'string', min => 1, optional => 1 },
reason_cleanup_failed => { type => 'string', min => 1, optional => 1 },
reason_probe_succeeded => { type => 'string', min => 1, optional => 1 },
reason_needs_root => { type => 'string', min => 1, optional => 1 },
report_header => { type => 'string', min => 1, optional => 1 },
report_yes => { type => 'string', min => 1, optional => 1 },
report_no => { type => 'string', min => 1, optional => 1 },
}
Output
{ type => 'void' }
MESSAGES
Errors (the call dies, and no text is changed):
Unknown message key '$key' (error_unknown_message)
What to do: use a key listed under MESSAGES.
(an error from Params::Validate::Strict or Params::Get)
A text is empty or not a string, or the arguments are not pairs.
FAILURE POLICY
Caller errors croak: an unknown kind, a
dirthat is not an existing directory, apaththat does not exist, bad arguments (reported by Params::Validate::Strict or Params::Get), or an unknown message key.with_revokedalso croaks if it cannot change or restore the mode it was asked to change.Probe errors never escape. Anything that goes wrong inside a probe becomes a 0 answer with a reason. A helper whose job is to decide whether to skip must never be the thing that fails your test file.
No warnings. Many suites run under Test::Warnings or
-W, so a warning would itself fail them. Use "why_not(kind, dir)" for diagnostics.A failed restore or cleanup makes the answer 0, and is added to the reason (
reason_cleanup_failed).
LIMITATIONS
Results are per directory (or per device, see "set_cache_scope(scope)"), per effective user and groups, and per process.
A directory whose permissions or mount change after the first call keeps its cached answer until "clear_cache()".
Probing needs write access to
dir: the probe creates a scratch directory there. Without it the answer is 0 (reason_setup_failed).The probes change only mode bits. "acl_denies(kind, path)" detects an ACL that denies access to an existing path, but no probe creates ACLs.
The mode check after
chmodcompares the owner's permission bits (all bits forsticky), because the probe runs as the owner of its scratch files, and because perl on Windows reports a read-only file as 0444.The
stickyprobe needs root, and assumes that uids 65533 and 65534 can own files. In a user namespace that maps only one uid (such asunshare -r), giving the file away fails and the answer is 0 withreason_setup_failed.Reasons embed the system's error text in the current locale.
Needs perl 5.26 or later, because Params::Validate::Strict does.
SEE ALSO
Test::More, Test2::V0, Test::Warnings, File::Temp, filetest.
SUPPORT
This module is provided as-is without any warranty.
Please report bugs and feature requests at https://github.com/nigelhorne/Test-Permissions/issues.
AUTHOR
Nigel Horne <njh@nigelhorne.com>
STATE DIAGRAM
The cache, for one key: a kind, the effective uid and groups, and the canonical directory (or, with the device scope, its device). can_revoke, can_revoke_*, why_not, skip_unless_can_revoke and permissions_report are all "queries". A call that croaks does not change the state.
query: probe, answer 1
+---------+ ---------------------------> +------------+
| EMPTY | | CACHED_YES | --+ query: no probe
+---------+ ---------------------------> +------------+ <-+
^ ^ ^ query: probe, answer 0 |
| | | |
| | +------------- clear_cache ------------+
| | +-----------+
| +-------------- clear_cache ---------- | CACHED_NO | --+ query: no probe
| +-----------+ <-+
+-- clear_cache (from EMPTY: no change)
There is no edge between CACHED_YES and CACHED_NO: a cached answer stays until clear_cache, even if the environment changes. set_messages changes no state; a cached reason keeps the wording it was given when the probe ran. set_cache_scope and a change of effective uid or groups do not change any key's state; they change which key later queries use.
FORMAL SPECIFICATION
The specification below uses Z notation. The English sections above are the normative description for everyday use.
[PATH, ERRNO, CHAR, UID, GID, DEV]
Kind ::= read | write | create | search | exec | delete | sticky
Answer == { 0, 1 }
DENIED == { EACCES, EPERM }
Reason == seq₁ CHAR
Scope ::= directory | device
-- Outcome of one probe step.
Outcome ::= ok | failed⟨⟨ERRNO⟩⟩ | threw
restricted : Kind → ℕ
restricted = { read ↦ 0, write ↦ 0400, create ↦ 0500, search ↦ 0,
exec ↦ 0600, delete ↦ 0500, sticky ↦ 01777 }
bits : Kind → ℕ
bits = (λ k : Kind • 0700) ⊕ { sticky ↦ 07777 }
Env ≙ [ ruid, euid : UID; egids : 𝔽 GID; windows : 𝔹 ]
precondition : Kind × Env → 𝔹
precondition(k, e) ⇔ k ≠ sticky ∨ (e.ruid = 0 ∧ e.euid = 0 ∧ ¬ e.windows)
Probe
kind? : Kind
dir? : PATH
env : Env
setup, baseline, attempt, cleanup : Outcome
gotMode : ℕ
modeSet : 𝔹
answer! : Answer
reason! : Reason ∪ {⊥}
---------------------------------------------
modeSet ⇔ gotMode ∧ bits(kind?) = restricted(kind?) ∧ bits(kind?)
answer! = 1 ⇔ precondition(kind?, env)
∧ setup = ok ∧ baseline = ok ∧ modeSet
∧ (∃ e : DENIED • attempt = failed(e))
∧ cleanup = ok
answer! = 1 ⇔ reason! = ⊥
Key == Kind × UID × 𝔽 GID × (PATH ∪ DEV)
Cache == Key ⇸ Answer × (Reason ∪ {⊥})
scope : Scope
key : Kind × Env × PATH → Key
key(k, e, p) = (k, e.euid, e.egids, if scope = device then dev(p) else canon(p))
CanRevoke
ΔCache ; Probe
---------------------------------------------
key(kind?, env, dir?) ∈ dom cache ⇒ cache' = cache
∧ answer! = first(cache key(kind?, env, dir?))
key(kind?, env, dir?) ¬in; dom cache ⇒ cache' = cache ∪ { key(kind?, env, dir?) ↦ (answer!, reason!) }
WhyNot
ΞCache after CanRevoke
why! : Reason ∪ {⊥}
---------------------------------------------
why! = second(cache key(kind?, env, dir?))
SkipUnlessCanRevoke
CanRevoke
count? : ℕ₁
skipped! : ℕ
---------------------------------------------
answer! = 1 ⇒ skipped! = 0 ∧ the SKIP block continues
answer! = 0 ⇒ skipped! = count? ∧ the SKIP block is left,
each skip carrying second(cache key(kind?, env, dir?))
ClearCache
ΔCache
---------------------------------------------
cache' = ∅
SetCacheScope
ΞCache
scope?, scope, scope' : Scope
---------------------------------------------
scope' = scope?
SetMessages
texts, texts' : KEY ⇸ seq₁ CHAR
overrides? : KEY ⇸ seq CHAR
---------------------------------------------
(dom overrides? ⊆ dom DEFAULTS ∧ ⊥ ¬in; ran overrides? ∧ ⟨⟩ ¬in; ran overrides?)
⇒ texts' = texts ⊕ overrides?
¬ (...) ⇒ texts' = texts ∧ croak
AclDenies
kind? : { read, write, exec }
path? : PATH
denied! : Answer
---------------------------------------------
denied! = 1 ⇔ modeAllows(kind?, stat(path?), env) ∧ ¬ access(kind?, path?)
WithRevoked
kind? : Kind \ { sticky }
path? : PATH
code? : ⊤ → ⊤
---------------------------------------------
mode'(path?) = mode(path?) -- restored, even if code? dies
during code? : mode(path?) = restricted(kind?)
result! = code?() ∨ code?'s exception, unchanged
-- Invariant: a probe leaves the target directory as it found it.
entries'(dir?) = entries(dir?)
LICENSE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.