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 chmod only sets the read-only attribute;

  • fakeroot, and containers where a non-root user holds CAP_DAC_OVERRIDE;

  • filesystems that ignore mode bits (FAT, some SMB/NFS/FUSE mounts, Cygwin noacl mounts);

  • 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, with stat, that the owner's permission bits (for sticky: 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 EACCES or EPERM for 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 $@, $! or umask.

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

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

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

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

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

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

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 of read, write, create, search, exec, delete or sticky.

  • 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

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 count skipped tests. With Test::More (or anything else that loads Test::Builder) it calls Test::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, like Test::More::skip. Outside one, perl dies with Label 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, write or exec (for a directory, exec means 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) or exec (0600), which apply to a file; or create (0500), search (0) or delete (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 path while code runs, then restores the mode it had before.

  • Does not check that chmod really takes the access away: use "can_revoke(kind, dir)" (or skip_unless_can_revoke) first.

  • Under taint mode, path is 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

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 same st_dev) share answers. A suite that makes a new tempdir for 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. directory or device.

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 %s than 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 dir that is not an existing directory, a path that does not exist, bad arguments (reported by Params::Validate::Strict or Params::Get), or an unknown message key. with_revoked also 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 chmod compares the owner's permission bits (all bits for sticky), because the probe runs as the owner of its scratch files, and because perl on Windows reports a read-only file as 0444.

  • The sticky probe needs root, and assumes that uids 65533 and 65534 can own files. In a user namespace that maps only one uid (such as unshare -r), giving the file away fails and the answer is 0 with reason_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.