[Date Prev][Date Next] [Thread Prev][Thread Next] [Date Index] [Thread Index]

Bug#850830: marked as done (jessie-pu: package ikiwiki/3.20141016.4)



Your message dated Wed, 11 Jan 2017 19:19:27 +0000
with message-id <20170111191927.kdzfpxtupv37nw4o@perpetual.pseudorandom.co.uk>
and subject line Re: jessie-pu: package ikiwiki/3.20141016.4
has caused the Debian Bug report #850830,
regarding jessie-pu: package ikiwiki/3.20141016.4
to be marked as done.

This means that you claim that the problem has been dealt with.
If this is not the case it is now your responsibility to reopen the
Bug report if necessary, and/or fix the problem forthwith.

(NB: If you are a system administrator and have no idea what this
message is talking about, this may indicate a serious mail system
misconfiguration somewhere. Please contact owner@bugs.debian.org
immediately.)


-- 
850830: http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=850830
Debian Bug Tracking System
Contact owner@bugs.debian.org with problems
--- Begin Message ---
Package: release.debian.org
Severity: normal
Tags: jessie
User: release.debian.org@packages.debian.org
Usertags: pu

I recently fixed some minor security vulnerabilities in ikiwiki, for
which the Security Team declined to issue a DSA. I'd like to get their
fixes into stable.

If possible I would also like to backport related non-security bug fixes,
and the ability to run the test suite with autopkgtest.

Here is a proposed changelog with justification; diff attached.

Code changes:
 IkiWiki.pm                        |    2 
 IkiWiki/Plugin/attachment.pm      |    2 
 IkiWiki/Plugin/comments.pm        |   11 -
 IkiWiki/Plugin/editpage.pm        |    2 
 IkiWiki/Plugin/git.pm             |  324 +++++++++++++++++++++++++++-----------
 IkiWiki/Plugin/img.pm             |    2 
 IkiWiki/Plugin/notifyemail.pm     |    2 
 IkiWiki/Plugin/passwordauth.pm    |    4 
 IkiWiki/Plugin/po.pm              |    2 
 IkiWiki/Plugin/rename.pm          |    4 
 debian/changelog                  |   55 ++++++
 ikiwiki-makerepo                  |    6 

Test changes:
 debian/control                    |   10 +
 debian/tests/control              |   12 +
 debian/tests/pkg-perl/smoke-env   |    1 
 debian/tests/pkg-perl/syntax-skip |    4 
 debian/tests/pkg-perl/use-name    |    1 
 t/basewiki_brokenlinks.t          |   31 ++-
 t/comments.t                      |   21 ++
 t/conflicts.t                     |   19 +-
 t/cvs.t                           |   12 -
 t/git-cgi.t                       |  317 +++++++++++++++++++++++++++++++++++++
 t/git.t                           |   10 +
 t/html.t                          |    2 
 t/img.t                           |   28 ++-
 t/inline.t                        |   27 ++-
 t/permalink.t                     |   23 ++
 t/podcast.t                       |   31 ++-
 t/relativity.t                    |   18 +-
 t/syntax.t                        |    2 
 t/template_syntax.t               |    2 
 t/templates_documented.t          |    6 
 t/trail.t                         |   33 ++-

> ikiwiki (3.20141016.4) UNRELEASED; urgency=medium
> 
>  [ Simon McVittie ]
>  * Security: force CGI::FormBuilder->field to scalar context where
>    necessary, avoiding unintended function argument injection
>    analogous to CVE-2014-1572. In ikiwiki this could be used to
>    forge commit metadata, but thankfully nothing more serious.
>    (CVE-2016-9646)

Minor security fix. This accounts for all the diff chunks that change
"$form->field(...)" to "scalar $form->field(...)" - a couple of them
potentially allow metadata forgery. The rest (notably including
the ones around passwords!) don't seem to be exploitable, but are
fixed as defensive programming in case I was wrong.

>  * Backport IkiWiki::Plugin::git from 3.20170110 to fix the following
>    bugs, including one minor security vulnerability:

This accounts for IkiWiki/Plugin/git.pm.

Rather than cherry-picking literally only the security fixes, I'd be
more confident about the result if I can backport the entire git plugin,
which has non-security bug fixes (many of which result from the testing
I did on the security fixes).

The only diff between my currently proposed git plugin and what's in
unstable is that the version in unstable adds a call to IkiWiki::cloak()
to mask email addresses. That function, and the related feature of logging
in with an email address, didn't exist in jessie.

>    - Security: try revert operations before approving them. Previously,
>      automatic rename detection could result in a revert writing outside
>      the wiki srcdir or altering a file that the reverting user should not be
>      able to alter, an authorization bypass.
>      (CVE-2016-10026 represents the original vulnerability.)
>      The incomplete fix released in 3.20161219 was not effective for git
>      versions prior to 2.8.0rc0.
>      (CVE-2016-9645 represents that incomplete solution. Debian stable
>      was never vulnerable to this one.)
>    - Fix the warnings "cannot chdir to .../ikiwiki-temp-working: No such
>      file or directory" seen in the initial fixes for those security issues
>    - If no committer identity is known, set it to
>      "IkiWiki <ikiwiki.info>" in .git/config. This resolves commit errors
>      in versions of git that require a non-trivial committer identity.

Several rounds of fixing for some tricky problems with reverting. To get
the error rollback to work correctly, I had to change some global state
(the git directory to work in) into a series of function parameters,
which changed the calling convention for several internal functions.

>    - Use git log --no-renames to generate recentchanges, fixing the git
>      test-case with git 2.9 (Closes: #835612)

Could become relevant with a backported git, and it's a tiny change.
I've confirmed that it still works on Debian jessie.

>    - Don't issue a warning if the rcsinfo CGI parameter is undefined
>    - Do not fail to commit changes with a recent git version
>      and an anonymous committer
>    - Do not fail on filenames starting with a dash
>      (patch from Florian Wagner)
>    - Don't add a redundant "--" and run "git rev-list ... -- -- ..."

Mostly things I found while writing the test-case for CVE-2016-10026
and making it work...

>  * Backport IkiWiki::Plugin::img from 3.20160905 to fix a regression
>    in 3.20141016.3:
>    - img: ignore the case of the extension when detecting image format,
>      fixing the regression that *.JPG etc. would not be displayed
>      (patch from Amitai Schleier)

The previous security fix was over-zealous and can be relaxed.
This is a one-line change in IkiWiki/Plugin/img.pm, plus test coverage.

>  * Backport a change from 3.20170110 to log "bad page name <whatever>"
>    as intended, not the literal string "bad page name %s"

Found while testing CVE-2016-10026. One-line fix in IkiWiki.pm.

>  * Backport t/git-cgi.t from 3.20170110 to have automated test coverage
>    for using the CGI with git, including tests for CVE-2016-10026
>    - Build-depend on libipc-run-perl for better build-time test coverage

I found CVE-2016-9645 and CVE-2016-9646 with this test (in particular,
I only found CVE-2016-9645 because I ran this test on jessie). I would like
to keep the new coverage if at all possible: it does not affect the
contents of the binary package.

>  * Add CVE-2016-4561 reference to 3.20141016.3 changelog

Trivial historical cleanup, I didn't receive a CVE ID until later.

>  * debian/tests/control: set INSTALLED_TESTS=1 here, since
>    pkg-perl-autopkgtest in jessie didn't support
>    debian/tests/pkg-perl/smoke-env. Backported from the version in
>    jessie-backports.
>  * Backport tests' installed-test (autopkgtest) support from 3.20160121
>  * d/control: add enough build-dependencies to run all tests, except for
>    non-git VCSs

This accounts for debian/tests/* and most of t/*. I'm aware these are
not really stable-update material and I'd be willing to drop them, but if
the SRMs are willing to tolerate the diffstat, I would like to have
the autopkgtests - they make it easier to be confident that current and
subsequent security fixes are working properly. These changes do not affect
the contents of binary packages.

Thanks for considering,
    S
diff --git a/IkiWiki.pm b/IkiWiki.pm
index b55078af0..790e8f02c 100644
--- a/IkiWiki.pm
+++ b/IkiWiki.pm
@@ -1749,7 +1749,7 @@ sub check_canchange (@) {
 		$file=possibly_foolish_untaint($file);
 		if (! defined $file || ! length $file ||
 		    file_pruned($file)) {
-			error(gettext("bad file name %s"), $file);
+			error(sprintf(gettext("bad file name %s"), $file));
 		}
 
 		my $type=pagetype($file);
diff --git a/IkiWiki/Plugin/attachment.pm b/IkiWiki/Plugin/attachment.pm
index 9bac96fc6..04100e0fd 100644
--- a/IkiWiki/Plugin/attachment.pm
+++ b/IkiWiki/Plugin/attachment.pm
@@ -163,7 +163,7 @@ sub formbuilder (@) {
 	
 	# Generate the attachment list only after having added any new
 	# attachments.
-	$form->tmpl_param("attachment_list" => [attachment_list($form->field('page'))]);
+	$form->tmpl_param("attachment_list" => [attachment_list(scalar $form->field('page'))]);
 }
 
 sub attachment_holding_location {
diff --git a/IkiWiki/Plugin/comments.pm b/IkiWiki/Plugin/comments.pm
index c5177833f..aa9b49c8c 100644
--- a/IkiWiki/Plugin/comments.pm
+++ b/IkiWiki/Plugin/comments.pm
@@ -555,11 +555,12 @@ sub editcomment ($$) {
 		}
 		
 		$postcomment=1;
-		my $ok=IkiWiki::check_content(content => $form->field('editcontent'),
-			subject => $form->field('subject'),
+		my $ok=IkiWiki::check_content(
+			content => scalar $form->field('editcontent'),
+			subject => scalar $form->field('subject'),
 			$config{comments_allowauthor} ? (
-				author => $form->field('author'),
-				url => $form->field('url'),
+				author => scalar $form->field('author'),
+				url => scalar $form->field('url'),
 			) : (),
 			page => $location,
 			cgi => $cgi,
@@ -599,7 +600,7 @@ sub editcomment ($$) {
 				length $form->field('subject')) {
 				$message = sprintf(
 					gettext("Added a comment: %s"),
-					$form->field('subject'));
+					scalar $form->field('subject'));
 			}
 
 			IkiWiki::rcs_add($file);
diff --git a/IkiWiki/Plugin/editpage.pm b/IkiWiki/Plugin/editpage.pm
index 78d0704c7..fad7ecc5a 100644
--- a/IkiWiki/Plugin/editpage.pm
+++ b/IkiWiki/Plugin/editpage.pm
@@ -430,7 +430,7 @@ sub cgi_editpage ($$) {
 			$conflict=rcs_commit(
 				file => $file,
 				message => $message,
-				token => $form->field("rcsinfo"),
+				token => scalar $form->field("rcsinfo"),
 				session => $session,
 			);
 			enable_commit_hook();
diff --git a/IkiWiki/Plugin/git.pm b/IkiWiki/Plugin/git.pm
index 75b89e476..010d6d54c 100644
--- a/IkiWiki/Plugin/git.pm
+++ b/IkiWiki/Plugin/git.pm
@@ -5,6 +5,7 @@ use warnings;
 use strict;
 use IkiWiki;
 use Encode;
+use File::Path qw{remove_tree};
 use URI::Escape q{uri_escape_utf8};
 use open qw{:utf8 :std};
 
@@ -153,40 +154,65 @@ sub genwrapper {
 	}
 }
 
-my $git_dir=undef;
-my $prefix=undef;
+# Loosely based on git-new-workdir from git contrib.
+sub create_temp_working_dir ($$) {
+	my $rootdir = shift;
+	my $branch = shift;
+	my $working = "$rootdir/.git/ikiwiki-temp-working";
+	remove_tree($working);
 
-sub in_git_dir ($$) {
-	$git_dir=shift;
-	my @ret=shift->();
-	$git_dir=undef;
-	$prefix=undef;
-	return @ret;
+	foreach my $dir ("", ".git") {
+		if (!mkdir("$working/$dir")) {
+			error("Unable to create $working/$dir: $!");
+		}
+	}
+
+	# Hooks are deliberately not included: we will commit to the temporary
+	# branch that is used in the temporary working tree, and we don't want
+	# to run the post-commit hook there.
+	#
+	# logs/refs is not included because we don't use the reflog.
+	# remotes, rr-cache, svn are similarly excluded.
+	foreach my $link ("config", "refs", "objects", "info", "packed-refs") {
+		if (!symlink("../../$link", "$working/.git/$link")) {
+			error("Unable to create symlink $working/.git/$link: $!");
+		}
+	}
+
+	open (my $out, '>', "$working/.git/HEAD") or
+		error("failed to write $working.git/HEAD: $!");
+	print $out "ref: refs/heads/$branch\n" or
+		error("failed to write $working.git/HEAD: $!");
+	close $out or
+		error("failed to write $working.git/HEAD: $!");
+	return $working;
 }
 
-sub safe_git (&@) {
+sub safe_git {
 	# Start a child process safely without resorting to /bin/sh.
 	# Returns command output (in list content) or success state
 	# (in scalar context), or runs the specified data handler.
 
-	my ($error_handler, $data_handler, @cmdline) = @_;
+	my %params = @_;
 
 	my $pid = open my $OUT, "-|";
 
+	error("Working directory not specified") unless defined $params{chdir};
 	error("Cannot fork: $!") if !defined $pid;
 
 	if (!$pid) {
 		# In child.
 		# Git commands want to be in wc.
-		if (! defined $git_dir) {
-			chdir $config{srcdir}
-			    or error("cannot chdir to $config{srcdir}: $!");
+		if ($params{chdir} ne '.') {
+			chdir $params{chdir}
+			    or error("cannot chdir to $params{chdir}: $!");
 		}
-		else {
-			chdir $git_dir
-			    or error("cannot chdir to $git_dir: $!");
+
+		if ($params{stdout}) {
+			open(STDOUT, '>&', $params{stdout}) or error("Cannot reopen stdout: $!");
 		}
-		exec @cmdline or error("Cannot exec '@cmdline': $!");
+
+		exec @{$params{cmdline}} or error("Cannot exec '@{$params{cmdline}}': $!");
 	}
 	# In parent.
 
@@ -201,25 +227,51 @@ sub safe_git (&@) {
 
 		chomp;
 
-		if (! defined $data_handler) {
+		if (! defined $params{data_handler}) {
 			push @lines, $_;
 		}
 		else {
-			last unless $data_handler->($_);
+			last unless $params{data_handler}->($_);
 		}
 	}
 
 	close $OUT;
 
-	$error_handler->("'@cmdline' failed: $!") if $? && $error_handler;
+	$params{error_handler}->("'@{$params{cmdline}}' failed: $!") if $? && $params{error_handler};
 
 	return wantarray ? @lines : ($? == 0);
 }
 # Convenient wrappers.
-sub run_or_die ($@) { safe_git(\&error, undef, @_) }
-sub run_or_cry ($@) { safe_git(sub { warn @_ }, undef, @_) }
-sub run_or_non ($@) { safe_git(undef, undef, @_) }
+sub run_or_die_in ($$@) {
+	my $dir = shift;
+	safe_git(chdir => $dir, error_handler => \&error, cmdline => \@_);
+}
+sub run_or_cry_in ($$@) {
+	my $dir = shift;
+	safe_git(chdir => $dir, error_handler => sub { warn @_ }, cmdline => \@_);
+}
+sub run_or_non_in ($$@) {
+	my $dir = shift;
+	safe_git(chdir => $dir, cmdline => \@_);
+}
+
+sub ensure_committer ($) {
+	my $dir = shift;
+
+	if (! length $ENV{GIT_AUTHOR_NAME} || ! length $ENV{GIT_COMMITTER_NAME}) {
+		my $name = join('', run_or_non_in($dir, "git", "config", "user.name"));
+		if (! length $name) {
+			run_or_die_in($dir, "git", "config", "user.name", "IkiWiki");
+		}
+	}
 
+	if (! length $ENV{GIT_AUTHOR_EMAIL} || ! length $ENV{GIT_COMMITTER_EMAIL}) {
+		my $email = join('', run_or_non_in($dir, "git", "config", "user.email"));
+		if (! length $email) {
+			run_or_die_in($dir, "git", "config", "user.email", "ikiwiki.info");
+		}
+	}
+}
 
 sub merge_past ($$$) {
 	# Unlike with Subversion, Git cannot make a 'svn merge -rN:M file'.
@@ -258,6 +310,8 @@ sub merge_past ($$$) {
 	my @undo;      # undo stack for cleanup in case of an error
 	my $conflict;  # file content with conflict markers
 
+	ensure_committer($config{srcdir});
+
 	eval {
 		# Hide local changes from Git by renaming the modified file.
 		# Relative paths must be converted to absolute for renaming.
@@ -276,30 +330,30 @@ sub merge_past ($$$) {
 		my $branch = "throw_away_${sha1}"; # supposed to be unique
 
 		# Create a throw-away branch and rewind backward.
-		push @undo, sub { run_or_cry('git', 'branch', '-D', $branch) };
-		run_or_die('git', 'branch', $branch, $sha1);
+		push @undo, sub { run_or_cry_in($config{srcdir}, 'git', 'branch', '-D', $branch) };
+		run_or_die_in($config{srcdir}, 'git', 'branch', $branch, $sha1);
 
 		# Switch to throw-away branch for the merge operation.
 		push @undo, sub {
-			if (!run_or_cry('git', 'checkout', $config{gitmaster_branch})) {
-				run_or_cry('git', 'checkout','-f',$config{gitmaster_branch});
+			if (!run_or_cry_in($config{srcdir}, 'git', 'checkout', $config{gitmaster_branch})) {
+				run_or_cry_in($config{srcdir}, 'git', 'checkout','-f',$config{gitmaster_branch});
 			}
 		};
-		run_or_die('git', 'checkout', $branch);
+		run_or_die_in($config{srcdir}, 'git', 'checkout', $branch);
 
 		# Put the modified file in _this_ branch.
 		rename($hidden, $target)
 		    or error("rename '$hidden' to '$target' failed: $!");
 
 		# _Silently_ commit all modifications in the current branch.
-		run_or_non('git', 'commit', '-m', $message, '-a');
+		run_or_non_in($config{srcdir}, 'git', 'commit', '-m', $message, '-a');
 		# ... and re-switch to master.
-		run_or_die('git', 'checkout', $config{gitmaster_branch});
+		run_or_die_in($config{srcdir}, 'git', 'checkout', $config{gitmaster_branch});
 
 		# Attempt to merge without complaining.
-		if (!run_or_non('git', 'pull', '--no-commit', '.', $branch)) {
+		if (!run_or_non_in($config{srcdir}, 'git', 'pull', '--no-commit', '.', $branch)) {
 			$conflict = readfile($target);
-			run_or_die('git', 'reset', '--hard');
+			run_or_die_in($config{srcdir}, 'git', 'reset', '--hard');
 		}
 	};
 	my $failure = $@;
@@ -315,7 +369,11 @@ sub merge_past ($$$) {
 	return $conflict;
 }
 
-sub decode_git_file ($) {
+{
+my %prefix_cache;
+
+sub decode_git_file ($$) {
+	my $dir=shift;
 	my $file=shift;
 
 	# git does not output utf-8 filenames, but instead
@@ -326,20 +384,22 @@ sub decode_git_file ($) {
 	}
 
 	# strip prefix if in a subdir
-	if (! defined $prefix) {
-		($prefix) = run_or_die('git', 'rev-parse', '--show-prefix');
-		if (! defined $prefix) {
-			$prefix="";
+	if (! defined $prefix_cache{$dir}) {
+		($prefix_cache{$dir}) = run_or_die_in($dir, 'git', 'rev-parse', '--show-prefix');
+		if (! defined $prefix_cache{$dir}) {
+			$prefix_cache{$dir}="";
 		}
 	}
-	$file =~ s/^\Q$prefix\E//;
+	$file =~ s/^\Q$prefix_cache{$dir}\E//;
 
 	return decode("utf8", $file);
 }
+}
 
-sub parse_diff_tree ($) {
+sub parse_diff_tree ($$) {
 	# Parse the raw diff tree chunk and return the info hash.
 	# See git-diff-tree(1) for the syntax.
+	my $dir = shift;
 	my $dt_ref = shift;
 
 	# End of stream?
@@ -408,6 +468,17 @@ sub parse_diff_tree ($) {
 	}
 	shift @{ $dt_ref } if $dt_ref->[0] =~ /^$/;
 
+	$ci{details} = [parse_changed_files($dir, $dt_ref)];
+
+	return \%ci;
+}
+
+sub parse_changed_files ($$) {
+	my $dir = shift;
+	my $dt_ref = shift;
+
+	my @files;
+
 	# Modified files.
 	while (my $line = shift @{ $dt_ref }) {
 		if ($line =~ m{^
@@ -425,8 +496,8 @@ sub parse_diff_tree ($) {
 			my $status = shift(@tmp);
 
 			if (length $file) {
-				push @{ $ci{'details'} }, {
-					'file'      => decode_git_file($file),
+				push @files, {
+					'file'      => decode_git_file($dir, $file),
 					'sha1_from' => $sha1_from[0],
 					'sha1_to'   => $sha1_to,
 					'mode_from' => $mode_from[0],
@@ -439,23 +510,23 @@ sub parse_diff_tree ($) {
 		last;
 	}
 
-	return \%ci;
+	return @files;
 }
 
-sub git_commit_info ($;$) {
+sub git_commit_info ($$;$) {
 	# Return an array of commit info hashes of num commits
 	# starting from the given sha1sum.
-	my ($sha1, $num) = @_;
+	my ($dir, $sha1, $num) = @_;
 
 	my @opts;
 	push @opts, "--max-count=$num" if defined $num;
 
-	my @raw_lines = run_or_die('git', 'log', @opts,
+	my @raw_lines = run_or_die_in($dir, 'git', 'log', @opts,
 		'--pretty=raw', '--raw', '--abbrev=40', '--always', '-c',
-		'-r', $sha1, '--', '.');
+		'-r', $sha1, '--no-renames', '--', '.');
 
 	my @ci;
-	while (my $parsed = parse_diff_tree(\@raw_lines)) {
+	while (my $parsed = parse_diff_tree($dir, \@raw_lines)) {
 		push @ci, $parsed;
 	}
 
@@ -472,7 +543,7 @@ sub rcs_find_changes ($) {
 	# merge commit where some files were not really added.
 	# This is why the code below verifies that the files really
 	# exist.
-	my @raw_lines = run_or_die('git', 'log',
+	my @raw_lines = run_or_die_in($config{srcdir}, 'git', 'log',
 		'--pretty=raw', '--raw', '--abbrev=40', '--always', '-c',
 		'--no-renames', , '--reverse',
 		'-r', "$oldrev..HEAD", '--', '.');
@@ -482,7 +553,7 @@ sub rcs_find_changes ($) {
 	my %deleted;
 	my $nullsha = 0 x 40;
 	my $newrev=$oldrev;
-	while (my $ci = parse_diff_tree(\@raw_lines)) {
+	while (my $ci = parse_diff_tree($config{srcdir}, \@raw_lines)) {
 		$newrev=$ci->{sha1};
 		foreach my $i (@{$ci->{details}}) {
 			my $file=$i->{file};
@@ -504,14 +575,16 @@ sub rcs_find_changes ($) {
 	return (\%changed, \%deleted, $newrev);
 }
 
-sub git_sha1_file ($) {
+sub git_sha1_file ($$) {
+	my $dir=shift;
 	my $file=shift;
-	git_sha1("--", $file);
+	return git_sha1($dir, $file);
 }
 
-sub git_sha1 (@) {
+sub git_sha1 ($@) {
+	my $dir = shift;
 	# Ignore error since a non-existing file might be given.
-	my ($sha1) = run_or_non('git', 'rev-list', '--max-count=1', 'HEAD',
+	my ($sha1) = run_or_non_in($dir, 'git', 'rev-list', '--max-count=1', 'HEAD',
 		'--', @_);
 	if (defined $sha1) {
 		($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
@@ -520,14 +593,15 @@ sub git_sha1 (@) {
 }
 
 sub rcs_get_current_rev () {
-	git_sha1();
+	return git_sha1($config{srcdir});
 }
 
 sub rcs_update () {
 	# Update working directory.
+	ensure_committer($config{srcdir});
 
 	if (length $config{gitorigin_branch}) {
-		run_or_cry('git', 'pull', '--prune', $config{gitorigin_branch});
+		run_or_cry_in($config{srcdir}, 'git', 'pull', '--prune', $config{gitorigin_branch});
 	}
 }
 
@@ -536,7 +610,7 @@ sub rcs_prepedit ($) {
 	# This will be later used in rcs_commit if a merge is required.
 	my ($file) = @_;
 
-	return git_sha1_file($file);
+	return git_sha1_file($config{srcdir}, $file);
 }
 
 sub rcs_commit (@) {
@@ -547,8 +621,11 @@ sub rcs_commit (@) {
 
 	# Check to see if the page has been changed by someone else since
 	# rcs_prepedit was called.
-	my $cur    = git_sha1_file($params{file});
-	my ($prev) = $params{token} =~ /^($sha1_pattern)$/; # untaint
+	my $cur = git_sha1_file($config{srcdir}, $params{file});
+	my $prev;
+	if (defined $params{token}) {
+		($prev) = $params{token} =~ /^($sha1_pattern)$/; # untaint
+	}
 
 	if (defined $cur && defined $prev && $cur ne $prev) {
 		my $conflict = merge_past($prev, $params{file}, $dummy_commit_msg);
@@ -578,20 +655,28 @@ sub rcs_commit_helper (@) {
 		elsif (defined $params{session}->remote_addr()) {
 			$u=$params{session}->remote_addr();
 		}
-		if (defined $u) {
+		if (length $u) {
 			$u=encode_utf8($u);
 			$ENV{GIT_AUTHOR_NAME}=$u;
 		}
+		else {
+			$u = 'anonymous';
+		}
 		if (defined $params{session}->param("nickname")) {
 			$u=encode_utf8($params{session}->param("nickname"));
 			$u=~s/\s+/_/g;
 			$u=~s/[^-_0-9[:alnum:]]+//g;
 		}
-		if (defined $u) {
+		if (length $u) {
 			$ENV{GIT_AUTHOR_EMAIL}="$u\@web";
 		}
+		else {
+			$ENV{GIT_AUTHOR_EMAIL}='anonymous@web';
+		}
 	}
 
+	ensure_committer($config{srcdir});
+
 	$params{message} = IkiWiki::possibly_foolish_untaint($params{message});
 	my @opts;
 	if ($params{message} !~ /\S/) {
@@ -615,10 +700,10 @@ sub rcs_commit_helper (@) {
 		push @opts, '--', $params{file};
 	}
 	# git commit returns non-zero if nothing really changed.
-	# So we should ignore its exit status (hence run_or_non).
-	if (run_or_non('git', 'commit', '-m', $params{message}, '-q', @opts)) {
+	# So we should ignore its exit status (hence run_or_non_in).
+	if (run_or_non_in($config{srcdir}, 'git', 'commit', '-m', $params{message}, '-q', @opts)) {
 		if (length $config{gitorigin_branch}) {
-			run_or_cry('git', 'push', $config{gitorigin_branch}, $config{gitmaster_branch});
+			run_or_cry_in($config{srcdir}, 'git', 'push', $config{gitorigin_branch}, $config{gitmaster_branch});
 		}
 	}
 	
@@ -631,7 +716,8 @@ sub rcs_add ($) {
 
 	my ($file) = @_;
 
-	run_or_cry('git', 'add', $file);
+	ensure_committer($config{srcdir});
+	run_or_cry_in($config{srcdir}, 'git', 'add', '--', $file);
 }
 
 sub rcs_remove ($) {
@@ -639,13 +725,15 @@ sub rcs_remove ($) {
 
 	my ($file) = @_;
 
-	run_or_cry('git', 'rm', '-f', $file);
+	ensure_committer($config{srcdir});
+	run_or_cry_in($config{srcdir}, 'git', 'rm', '-f', '--', $file);
 }
 
 sub rcs_rename ($$) {
 	my ($src, $dest) = @_;
 
-	run_or_cry('git', 'mv', '-f', $src, $dest);
+	ensure_committer($config{srcdir});
+	run_or_cry_in($config{srcdir}, 'git', 'mv', '-f', '--', $src, $dest);
 }
 
 sub rcs_recentchanges ($) {
@@ -657,7 +745,7 @@ sub rcs_recentchanges ($) {
 	error($@) if $@;
 
 	my @rets;
-	foreach my $ci (git_commit_info('HEAD', $num || 1)) {
+	foreach my $ci (git_commit_info($config{srcdir}, 'HEAD', $num || 1)) {
 		# Skip redundant commits.
 		next if ($ci->{'comment'} && @{$ci->{'comment'}}[0] eq $dummy_commit_msg);
 
@@ -743,7 +831,12 @@ sub rcs_diff ($;$) {
 			if (@lines || $line=~/^diff --git/);
 		return 1;
 	};
-	safe_git(undef, $addlines, "git", "show", $sha1);
+	safe_git(
+		chdir => $config{srcdir},
+		error_handler => undef,
+		data_handler => $addlines,
+		cmdline => ["git", "show", $sha1],
+	);
 	if (wantarray) {
 		return @lines;
 	}
@@ -761,7 +854,7 @@ sub findtimes ($$) {
 
 	if (! keys %time_cache) {
 		my $date;
-		foreach my $line (run_or_die('git', 'log',
+		foreach my $line (run_or_die_in($config{srcdir}, 'git', 'log',
 				'--pretty=format:%at',
 				'--name-only', '--relative')) {
 			if (! defined $date && $line =~ /^(\d+)$/) {
@@ -771,7 +864,7 @@ sub findtimes ($$) {
 				$date=undef;
 			}
 			else {
-				my $f=decode_git_file($line);
+				my $f=decode_git_file($config{srcdir}, $line);
 
 				if (! $time_cache{$f}) {
 					$time_cache{$f}[0]=$date; # mtime
@@ -823,7 +916,8 @@ sub git_find_root {
 
 }
 
-sub git_parse_changes {
+sub git_parse_changes ($$@) {
+	my $dir = shift;
 	my $reverted = shift;
 	my @changes = @_;
 
@@ -873,11 +967,12 @@ sub git_parse_changes {
 				die $@ if $@;
 				my $fh;
 				($fh, $path)=File::Temp::tempfile(undef, UNLINK => 1);
-				my $cmd = "cd $git_dir && ".
-				          "git show $detail->{sha1_to} > '$path'";
-				if (system($cmd) != 0) {
-					error("failed writing temp file '$path'.");
-				}
+				safe_git(
+					chdir => $dir,
+					error_handler => sub { error("failed writing temp file '$path': ".shift."."); },
+					stdout => $fh,
+					cmdline => ['git', 'show', $detail->{sha1_to}],
+				);
 			}
 
 			push @rets, {
@@ -907,9 +1002,7 @@ sub rcs_receive () {
 		# (Also, if a subdir is involved, we don't want to chdir to
 		# it and only see changes in it.)
 		# The pre-receive hook already puts us in the right place.
-		in_git_dir(".", sub {
-			push @rets, git_parse_changes(0, git_commit_info($oldrev."..".$newrev));
-		});
+		push @rets, git_parse_changes('.', 0, git_commit_info('.', $oldrev."..".$newrev));
 	}
 
 	return reverse @rets;
@@ -919,12 +1012,17 @@ sub rcs_preprevert ($) {
 	my $rev=shift;
 	my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
 
+	my @undo;      # undo stack for cleanup in case of an error
+
 	# Examine changes from root of git repo, not from any subdir,
 	# in order to see all changes.
 	my ($subdir, $rootdir) = git_find_root();
-	in_git_dir($rootdir, sub {
-		my @commits=git_commit_info($sha1, 1);
-	
+	ensure_committer($rootdir);
+
+	# preserve indentation of previous in_git_dir code for now
+	do {
+		my @commits=git_commit_info($rootdir, $sha1, 1);
+
 		if (! @commits) {
 			error "unknown commit"; # just in case
 		}
@@ -935,8 +1033,60 @@ sub rcs_preprevert ($) {
 			error gettext("you are not allowed to revert a merge");
 		}
 
-		git_parse_changes(1, @commits);
-	});
+		# Due to the presence of rename-detection, we cannot actually
+		# see what will happen in a revert without trying it.
+		# But we can guess, which is enough to rule out most changes
+		# that we won't allow reverting.
+		git_parse_changes($rootdir, 1, @commits);
+
+		my $failure;
+		my @ret;
+		eval {
+			my $branch = "ikiwiki_revert_${sha1}"; # supposed to be unique
+
+			push @undo, sub {
+				run_or_cry_in($rootdir, 'git', 'branch', '-D', $branch) if $failure;
+			};
+			if (run_or_non_in($rootdir, 'git', 'rev-parse', '--quiet', '--verify', $branch)) {
+				run_or_non_in($rootdir, 'git', 'branch', '-D', $branch);
+			}
+			run_or_die_in($rootdir, 'git', 'branch', $branch, $config{gitmaster_branch});
+
+			my $working = create_temp_working_dir($rootdir, $branch);
+
+			push @undo, sub {
+				remove_tree($working);
+			};
+
+			run_or_die_in($working, 'git', 'checkout', '--quiet', '--force', $branch);
+			run_or_die_in($working, 'git', 'revert', '--no-commit', $sha1);
+			run_or_die_in($working, 'git', 'commit', '-m', "revert $sha1", '-a');
+
+			my @raw_lines;
+			@raw_lines = run_or_die_in($rootdir, 'git', 'diff', '--pretty=raw',
+				'--raw', '--abbrev=40', '--always', '--no-renames',
+				"..${branch}");
+
+			my $ci = {
+				details => [parse_changed_files($rootdir, \@raw_lines)],
+			};
+
+			@ret = git_parse_changes($rootdir, 0, $ci);
+		};
+		$failure = $@;
+
+		# Process undo stack (in reverse order).  By policy cleanup
+		# actions should normally print a warning on failure.
+		while (my $handle = pop @undo) {
+			$handle->();
+		}
+
+		if ($failure) {
+			my $message = sprintf(gettext("Failed to revert commit %s"), $sha1);
+			error("$message\n$failure\n");
+		}
+		return @ret;
+	};
 }
 
 sub rcs_revert ($) {
@@ -944,11 +1094,13 @@ sub rcs_revert ($) {
 	my $rev = shift;
 	my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
 
-	if (run_or_non('git', 'revert', '--no-commit', $sha1)) {
+	ensure_committer($config{srcdir});
+
+	if (run_or_non_in($config{srcdir}, 'git', 'cherry-pick', '--no-commit', "ikiwiki_revert_$sha1")) {
 		return undef;
 	}
 	else {
-		run_or_die('git', 'reset', '--hard');
+		run_or_non_in($config{srcdir}, 'git', 'branch', '-D', "ikiwiki_revert_$sha1");
 		return sprintf(gettext("Failed to revert commit %s"), $sha1);
 	}
 }
diff --git a/IkiWiki/Plugin/img.pm b/IkiWiki/Plugin/img.pm
index c3dc41474..740c44f96 100644
--- a/IkiWiki/Plugin/img.pm
+++ b/IkiWiki/Plugin/img.pm
@@ -89,7 +89,7 @@ sub preprocess (@) {
 	my $extension;
 	my $format;
 
-	if ($base =~ m/\.([a-z0-9]+)$/) {
+	if ($base =~ m/\.([a-z0-9]+)$/is) {
 		$extension = $1;
 	}
 	else {
diff --git a/IkiWiki/Plugin/notifyemail.pm b/IkiWiki/Plugin/notifyemail.pm
index b50a22a00..079bb10d4 100644
--- a/IkiWiki/Plugin/notifyemail.pm
+++ b/IkiWiki/Plugin/notifyemail.pm
@@ -34,7 +34,7 @@ sub formbuilder (@) {
 	}
 	elsif ($form->submitted eq "Save Preferences" && $form->validate &&
 	       defined $form->field("subscriptions")) {
-		setsubscriptions($username, $form->field('subscriptions'));
+		setsubscriptions($username, scalar $form->field('subscriptions'));
 	}
 }
 
diff --git a/IkiWiki/Plugin/passwordauth.pm b/IkiWiki/Plugin/passwordauth.pm
index 0cf2a26ea..c1f4a85c0 100644
--- a/IkiWiki/Plugin/passwordauth.pm
+++ b/IkiWiki/Plugin/passwordauth.pm
@@ -231,7 +231,7 @@ sub formbuilder_setup (@) {
 				$form->field(
 					name => "password",
 					validate => sub {
-						checkpassword($form->field("name"), shift);
+						checkpassword(scalar $form->field("name"), shift);
 					},
 				);
 			}
@@ -389,7 +389,7 @@ sub formbuilder (@) {
 		if ($form->submitted eq "Save Preferences" && $form->validate) {
 			my $user_name=$form->field('name');
 			if (defined $form->field("password") && length $form->field("password")) {
-				setpassword($user_name, $form->field('password'));
+				setpassword($user_name, scalar $form->field('password'));
 			}
 		}
 	}
diff --git a/IkiWiki/Plugin/po.pm b/IkiWiki/Plugin/po.pm
index 6107a4a22..1528f235f 100644
--- a/IkiWiki/Plugin/po.pm
+++ b/IkiWiki/Plugin/po.pm
@@ -548,7 +548,7 @@ sub formbuilder_setup (@) {
 		# their buttons, which is why this hook must be run last.
 		# The canrename/canremove hooks already ensure this is forbidden
 		# at the backend level, so this is only UI sugar.
-		if (istranslation($form->field("page"))) {
+		if (istranslation(scalar $form->field("page"))) {
 			map {
 				for (my $i = 0; $i < @{$params{buttons}}; $i++) {
 					if (@{$params{buttons}}[$i] eq $_) {
diff --git a/IkiWiki/Plugin/rename.pm b/IkiWiki/Plugin/rename.pm
index 6d56340b8..2456c22cb 100644
--- a/IkiWiki/Plugin/rename.pm
+++ b/IkiWiki/Plugin/rename.pm
@@ -258,7 +258,7 @@ sub formbuilder (@) {
 		my $session=$params{session};
 
 		if ($form->submitted eq "Rename" && $form->field("do") eq "edit") {
-			rename_start($q, $session, 0, $form->field("page"));
+			rename_start($q, $session, 0, scalar $form->field("page"));
 		}
 		elsif ($form->submitted eq "Rename Attachment") {
 			my @selected=map { Encode::decode_utf8($_) } $q->param("attachment_select");
@@ -311,7 +311,7 @@ sub sessioncgi ($$) {
 			# performed in check_canrename later.
 			my $srcfile=IkiWiki::possibly_foolish_untaint($pagesources{$src})
 				if exists $pagesources{$src};
-			my $dest=IkiWiki::possibly_foolish_untaint(titlepage($form->field("new_name")));
+			my $dest=IkiWiki::possibly_foolish_untaint(titlepage(scalar $form->field("new_name")));
 			my $destfile=$dest;
 			if (! $q->param("attachment")) {
 				my $type=$q->param('type');
diff --git a/debian/changelog b/debian/changelog
index c6d92d117..7cc313afe 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,10 +1,63 @@
+ikiwiki (3.20141016.4) UNRELEASED; urgency=medium
+
+  [ Simon McVittie ]
+  * Security: force CGI::FormBuilder->field to scalar context where
+    necessary, avoiding unintended function argument injection
+    analogous to CVE-2014-1572. In ikiwiki this could be used to
+    forge commit metadata, but thankfully nothing more serious.
+    (CVE-2016-9646)
+  * Backport IkiWiki::Plugin::git from 3.20170110 to fix the following
+    bugs, including one minor security vulnerability:
+    - Security: try revert operations before approving them. Previously,
+      automatic rename detection could result in a revert writing outside
+      the wiki srcdir or altering a file that the reverting user should not be
+      able to alter, an authorization bypass.
+      (CVE-2016-10026 represents the original vulnerability.)
+      The incomplete fix released in 3.20161219 was not effective for git
+      versions prior to 2.8.0rc0.
+      (CVE-2016-9645 represents that incomplete solution. Debian stable
+      was never vulnerable to this one.)
+    - Fix the warnings "cannot chdir to .../ikiwiki-temp-working: No such
+      file or directory" seen in the initial fixes for those security issues
+    - If no committer identity is known, set it to
+      "IkiWiki <ikiwiki.info>" in .git/config. This resolves commit errors
+      in versions of git that require a non-trivial committer identity.
+    - Use git log --no-renames to generate recentchanges, fixing the git
+      test-case with git 2.9 (Closes: #835612)
+    - Don't issue a warning if the rcsinfo CGI parameter is undefined
+    - Do not fail to commit changes with a recent git version
+      and an anonymous committer
+    - Do not fail on filenames starting with a dash
+      (patch from Florian Wagner)
+    - Don't add a redundant "--" and run "git rev-list ... -- -- ..."
+  * Backport IkiWiki::Plugin::img from 3.20160905 to fix a regression
+    in 3.20141016.3:
+    - img: ignore the case of the extension when detecting image format,
+      fixing the regression that *.JPG etc. would not be displayed
+      (patch from Amitai Schleier)
+  * Backport a change from 3.20170110 to log "bad page name <whatever>"
+    as intended, not the literal string "bad page name %s"
+  * Backport t/git-cgi.t from 3.20170110 to have automated test coverage
+    for using the CGI with git, including tests for CVE-2016-10026
+    - Build-depend on libipc-run-perl for better build-time test coverage
+  * Add CVE-2016-4561 reference to 3.20141016.3 changelog
+  * debian/tests/control: set INSTALLED_TESTS=1 here, since
+    pkg-perl-autopkgtest in jessie didn't support
+    debian/tests/pkg-perl/smoke-env. Backported from the version in
+    jessie-backports.
+  * Backport tests' installed-test (autopkgtest) support from 3.20160121
+  * d/control: add enough build-dependencies to run all tests, except for
+    non-git VCSs
+
+ -- Simon McVittie <smcv@debian.org>  Mon, 09 May 2016 22:35:16 +0100
+
 ikiwiki (3.20141016.3) jessie-security; urgency=high
 
   [ Simon McVittie ]
   * img: stop ImageMagick trying to be clever if filenames contain a colon,
     avoiding mis-processing
   * HTML-escape error messages, in one case avoiding potential cross-site
-    scripting (OVE-20160505-0012)
+    scripting (CVE-2016-4561, OVE-20160505-0012)
   * Mitigate ImageMagick vulnerabilities such as CVE-2016-3714:
     - img: force common Web formats to be interpreted according to extension,
       so that "allowed_attachments: '*.jpg'" does what one might expect
diff --git a/debian/control b/debian/control
index 68f543a24..d2011bf4e 100644
--- a/debian/control
+++ b/debian/control
@@ -3,6 +3,7 @@ Section: web
 Priority: optional
 Build-Depends: perl, debhelper (>= 9)
 Build-Depends-Indep: dpkg-dev (>= 1.9.0), libxml-simple-perl,
+  git (>= 1:1.7),
   libtext-markdown-discount-perl,
   libtimedate-perl, libhtml-template-perl,
   libhtml-scrubber-perl, wdg-html-validator,
@@ -10,12 +11,19 @@ Build-Depends-Indep: dpkg-dev (>= 1.9.0), libxml-simple-perl,
   libfile-chdir-perl, libyaml-libyaml-perl, librpc-xml-perl,
   libcgi-pm-perl, libcgi-session-perl, ghostscript,
   libmagickcore-extra,
-  libcgi-formbuilder-perl
+  libcgi-formbuilder-perl,
+  libfile-mimeinfo-perl,
+  libipc-run-perl,
+  libnet-openid-consumer-perl,
+  libxml-feed-perl,
+  libxml-parser-perl,
+  libxml-twig-perl
 Maintainer: Simon McVittie <smcv@debian.org>
 Uploaders: Josh Triplett <josh@freedesktop.org>
 Standards-Version: 3.9.5
 Homepage: http://ikiwiki.info/
 Vcs-Git: git://git.ikiwiki.info/
+Testsuite: autopkgtest-pkg-perl
 
 Package: ikiwiki
 Architecture: all
diff --git a/debian/tests/control b/debian/tests/control
new file mode 100644
index 000000000..8b0ce72f9
--- /dev/null
+++ b/debian/tests/control
@@ -0,0 +1,12 @@
+Test-Command: env INSTALLED_TESTS=1 /usr/share/pkg-perl-autopkgtest/runner build-deps
+Depends: @, @builddeps@, pkg-perl-autopkgtest
+
+Test-Command: env INSTALLED_TESTS=1 /usr/share/pkg-perl-autopkgtest/runner runtime-deps
+Depends: @, pkg-perl-autopkgtest
+
+Test-Command: env INSTALLED_TESTS=1 /usr/share/pkg-perl-autopkgtest/runner runtime-deps-and-recommends
+Depends: @, pkg-perl-autopkgtest
+Restrictions: needs-recommends
+
+Test-Command: env INSTALLED_TESTS=1 /usr/share/pkg-perl-autopkgtest/runner heavy-deps
+Depends: @, pkg-perl-autopkgtest, pkg-perl-autopkgtest-heavy
diff --git a/debian/tests/pkg-perl/smoke-env b/debian/tests/pkg-perl/smoke-env
new file mode 100644
index 000000000..774738148
--- /dev/null
+++ b/debian/tests/pkg-perl/smoke-env
@@ -0,0 +1 @@
+INSTALLED_TESTS=1
diff --git a/debian/tests/pkg-perl/syntax-skip b/debian/tests/pkg-perl/syntax-skip
new file mode 100644
index 000000000..404e431d3
--- /dev/null
+++ b/debian/tests/pkg-perl/syntax-skip
@@ -0,0 +1,4 @@
+IkiWiki/Plugin/amazon_s3.pm
+IkiWiki/Plugin/cvs.pm
+IkiWiki/Plugin/monotone.pm
+IkiWiki/Plugin/po.pm
diff --git a/debian/tests/pkg-perl/use-name b/debian/tests/pkg-perl/use-name
new file mode 100644
index 000000000..3d60011b3
--- /dev/null
+++ b/debian/tests/pkg-perl/use-name
@@ -0,0 +1 @@
+IkiWiki
diff --git a/ikiwiki-makerepo b/ikiwiki-makerepo
index c3a13c214..f1c44067e 100755
--- a/ikiwiki-makerepo
+++ b/ikiwiki-makerepo
@@ -85,6 +85,12 @@ git)
 
 	cd "$srcdir"
 	git init
+	if [ -z "$(git config user.name)" ]; then
+		git config user.name IkiWiki
+	fi
+	if [ -z "$(git config user.email)" ]; then
+		git config user.email ikiwiki.info
+	fi
 	echo /.ikiwiki > .gitignore
 	git add .
 	git commit -m "initial commit"
diff --git a/t/basewiki_brokenlinks.t b/t/basewiki_brokenlinks.t
index 74ddc61c5..26e3859ab 100755
--- a/t/basewiki_brokenlinks.t
+++ b/t/basewiki_brokenlinks.t
@@ -1,21 +1,32 @@
 #!/usr/bin/perl
 use warnings;
 use strict;
-use Test::More 'no_plan';
+use Test::More;
+
+my $installed = $ENV{INSTALLED_TESTS};
 
 ok(! system("rm -rf t/tmp; mkdir t/tmp"));
-ok(! system("make -s ikiwiki.out"));
-ok(! system("make underlay_install DESTDIR=`pwd`/t/tmp/install PREFIX=/usr >/dev/null"));
+
+my @command;
+if ($installed) {
+	@command = qw(env LC_ALL=C ikiwiki);
+}
+else {
+	ok(! system("make -s ikiwiki.out"));
+	ok(! system("make underlay_install DESTDIR=`pwd`/t/tmp/install PREFIX=/usr >/dev/null"));
+	@command = qw(env LC_ALL=C perl -I. ./ikiwiki.out
+		--underlaydir=t/tmp/install/usr/share/ikiwiki/basewiki
+		--set underlaydirbase=t/tmp/install/usr/share/ikiwiki
+		--templatedir=templates);
+}
 
 foreach my $plugin ("", "listdirectives") {
-	ok(! system("LC_ALL=C perl -I. ./ikiwiki.out -rebuild -plugin brokenlinks ".
+	ok(! system(@command, qw(--rebuild --plugin brokenlinks),
 			# always enabled because pages link to it conditionally,
 			# which brokenlinks cannot handle properly
-			"-plugin smiley ".
-			($plugin ? "-plugin $plugin " : "").
-			"-underlaydir=t/tmp/install/usr/share/ikiwiki/basewiki ".
-			"-set underlaydirbase=t/tmp/install/usr/share/ikiwiki ".
-			"-templatedir=templates t/basewiki_brokenlinks t/tmp/out"));
+			qw(--plugin smiley),
+			($plugin ? ("--plugin", $plugin) : ()),
+			qw(t/basewiki_brokenlinks t/tmp/out)));
 	my $result=`grep 'no broken links' t/tmp/out/index.html`;
 	ok(length($result));
 	if (! length $result) {
@@ -27,3 +38,5 @@ foreach my $plugin ("", "listdirectives") {
 	ok(! system("rm -rf t/tmp/out t/basewiki_brokenlinks/.ikiwiki"));
 }
 ok(! system("rm -rf t/tmp"));
+
+done_testing();
diff --git a/t/comments.t b/t/comments.t
index da2148b6b..a5add9701 100755
--- a/t/comments.t
+++ b/t/comments.t
@@ -1,7 +1,7 @@
 #!/usr/bin/perl
 use warnings;
 use strict;
-use Test::More 'no_plan';
+use Test::More;
 use IkiWiki;
 
 ok(! system("rm -rf t/tmp"));
@@ -9,6 +9,20 @@ ok(mkdir "t/tmp");
 ok(! system("cp -R t/tinyblog t/tmp/in"));
 ok(mkdir "t/tmp/in/post" or -d "t/tmp/in/post");
 
+my $installed = $ENV{INSTALLED_TESTS};
+
+my @command;
+if ($installed) {
+	@command = qw(ikiwiki);
+}
+else {
+	ok(! system("make -s ikiwiki.out"));
+	@command = qw(perl -I. ./ikiwiki.out
+		--underlaydir=underlays/basewiki
+		--set underlaydirbase=underlays
+		--templatedir=templates);
+}
+
 my $comment;
 
 $comment = <<EOF;
@@ -39,8 +53,7 @@ ok(utime(222222222, 222222222, "t/tmp/in/post/comment_2._comment"));
 ok(utime(333333333, 333333333, "t/tmp/in/post/comment_1._comment"));
 
 # Build the wiki
-ok(! system("make -s ikiwiki.out"));
-ok(! system("perl -I. ./ikiwiki.out -verbose -plugin comments -url=http://example.com -cgiurl=http://example.com/ikiwiki.cgi -rss -atom -underlaydir=underlays/basewiki -set underlaydirbase=underlays -set comments_pagespec='*' -templatedir=templates t/tmp/in t/tmp/out"));
+ok(! system(@command, qw(--verbose --plugin comments --url=http://example.com --cgiurl=http://example.com/ikiwiki.cgi --rss --atom --set comments_pagespec=* t/tmp/in t/tmp/out)));
 
 # Check that the comments are in the right order
 
@@ -55,3 +68,5 @@ sub slurp {
 my $content = slurp("t/tmp/out/post/index.html");
 ok(defined $content);
 ok($content =~ m/I conquered.*I explored.*I landed/s);
+
+done_testing();
diff --git a/t/conflicts.t b/t/conflicts.t
index d7e04d3ae..07c392cd3 100755
--- a/t/conflicts.t
+++ b/t/conflicts.t
@@ -4,15 +4,30 @@ use warnings;
 use strict;
 use Test::More tests => 106;
 
+my $installed = $ENV{INSTALLED_TESTS};
+
+my @command;
+if ($installed) {
+	ok(1, "running installed");
+	@command = qw(ikiwiki);
+}
+else {
+	ok(! system("make -s ikiwiki.out"));
+	@command = qw(perl -I. ./ikiwiki.out
+		--underlaydir=underlays/basewiki
+		--set underlaydirbase=underlays
+		--templatedir=templates);
+}
+
 # setup
 my $srcdir="t/tmp/src";
 my $destdir="t/tmp/dest";
-ok(! system("make -s ikiwiki.out"));
 
 # runs ikiwiki to build test site
 sub runiki {
 	my $testdesc=shift;
-	ok((! system("perl -I. ./ikiwiki.out -plugin txt -plugin rawhtml -underlaydir=underlays/basewiki -set underlaydirbase=underlays -templatedir=templates $srcdir $destdir @_")),
+	ok((! system(@command, qw(--plugin txt --plugin rawhtml),
+				$srcdir, $destdir, @_)),
 		$testdesc);
 }
 sub refreshiki {
diff --git a/t/cvs.t b/t/cvs.t
index cbac43252..43a2ca3a8 100755
--- a/t/cvs.t
+++ b/t/cvs.t
@@ -4,6 +4,8 @@ use strict;
 use Test::More; my $total_tests = 72;
 use IkiWiki;
 
+my $installed = $ENV{INSTALLED_TESTS};
+
 my $default_test_methods = '^test_*';
 my @required_programs = qw(
 	cvs
@@ -606,12 +608,14 @@ sub _generate_and_configure_post_commit_hook {
 	$config{wrapper} = $config{cvs_wrapper};
 
 	require IkiWiki::Wrapper;
-	{
-		no warnings 'once';
+	if ($installed) {
 		$IkiWiki::program_to_wrap = 'ikiwiki.out';
-		# XXX substitute its interpreter to Makefile's $(PERL)
-		# XXX best solution: do this to all scripts during build
 	}
+	else {
+		$IkiWiki::program_to_wrap = `which ikiwiki`;
+	}
+	# XXX substitute its interpreter to Makefile's $(PERL)
+	# XXX best solution: do this to all scripts during build
 	IkiWiki::gen_wrapper();
 
 	my $cvs = "cvs -d $config{cvsrepo}";
diff --git a/t/git-cgi.t b/t/git-cgi.t
new file mode 100755
index 000000000..ee77257b9
--- /dev/null
+++ b/t/git-cgi.t
@@ -0,0 +1,317 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+
+use Test::More;
+
+BEGIN {
+	my $git = `which git`;
+	chomp $git;
+	plan(skip_all => 'git not available') unless -x $git;
+
+	plan(skip_all => "CGI not available")
+		unless eval q{
+			use CGI qw();
+			1;
+		};
+
+	plan(skip_all => "IPC::Run not available")
+		unless eval q{
+			use IPC::Run qw(run);
+			1;
+		};
+
+	use_ok('IkiWiki');
+	use_ok('YAML::XS');
+}
+
+# We check for English error messages
+$ENV{LC_ALL} = 'C';
+
+use Cwd qw(getcwd);
+use Errno qw(ENOENT);
+
+my $installed = $ENV{INSTALLED_TESTS};
+
+my @command;
+if ($installed) {
+	@command = qw(ikiwiki);
+}
+else {
+	ok(! system("make -s ikiwiki.out"));
+	@command = ("perl", "-I".getcwd."/blib/lib", './ikiwiki.out',
+		'--underlaydir='.getcwd.'/underlays/basewiki',
+		'--set', 'underlaydirbase='.getcwd.'/underlays',
+		'--templatedir='.getcwd.'/templates');
+}
+
+sub write_old_file {
+	my $name = shift;
+	my $dir = shift;
+	my $content = shift;
+	writefile($name, $dir, $content);
+	ok(utime(333333333, 333333333, "$dir/$name"));
+}
+
+sub write_setup_file {
+	my %setup = (
+		wikiname => 'this is the name of my wiki',
+		srcdir => getcwd.'/t/tmp/in/doc',
+		destdir => getcwd.'/t/tmp/out',
+		url => 'http://example.com',
+		cgiurl => 'http://example.com/cgi-bin/ikiwiki.cgi',
+		cgi_wrapper => getcwd.'/t/tmp/ikiwiki.cgi',
+		cgi_wrappermode => '0751',
+		add_plugins => [qw(anonok attachment lockedit recentchanges)],
+		disable_plugins => [qw(emailauth openid passwordauth)],
+		anonok_pagespec => 'writable/*',
+		locked_pages => '!writable/*',
+		rcs => 'git',
+		git_wrapper => getcwd.'/t/tmp/in/.git/hooks/post-commit',
+		git_wrappermode => '0754',
+		gitorigin_branch => '',
+	);
+	unless ($installed) {
+		$setup{ENV} = { 'PERL5LIB' => getcwd.'/blib/lib' };
+	}
+	writefile("test.setup", "t/tmp",
+		"# IkiWiki::Setup::Yaml - YAML formatted setup file\n" .
+		Dump(\%setup));
+}
+
+sub thoroughly_rebuild {
+	ok(unlink("t/tmp/ikiwiki.cgi") || $!{ENOENT});
+	ok(unlink("t/tmp/in/.git/hooks/post-commit") || $!{ENOENT});
+	ok(! system(@command, qw(--setup t/tmp/test.setup --rebuild --wrappers)));
+}
+
+sub check_cgi_mode_bits {
+	my $mode;
+
+	(undef, undef, $mode, undef, undef,
+		undef, undef, undef, undef, undef,
+		undef, undef, undef) = stat('t/tmp/ikiwiki.cgi');
+	is ($mode & 07777, 0751);
+	(undef, undef, $mode, undef, undef,
+		undef, undef, undef, undef, undef,
+		undef, undef, undef) = stat('t/tmp/in/.git/hooks/post-commit');
+	is ($mode & 07777, 0754);
+}
+
+sub run_cgi {
+	my (%args) = @_;
+	my ($in, $out);
+	my $method = $args{method} || 'GET';
+	my $environ = $args{environ} || {};
+	my $params = $args{params} || { do => 'prefs' };
+
+	my %defaults = (
+		SCRIPT_NAME	=> '/cgi-bin/ikiwiki.cgi',
+		HTTP_HOST	=> 'example.com',
+	);
+
+	my $cgi = CGI->new($args{params});
+	my $query_string = $cgi->query_string();
+
+	if ($method eq 'POST') {
+		$defaults{REQUEST_METHOD} = 'POST';
+		$in = $query_string;
+		$defaults{CONTENT_LENGTH} = length $in;
+	} else {
+		$defaults{REQUEST_METHOD} = 'GET';
+		$defaults{QUERY_STRING} = $query_string;
+	}
+
+	my %envvars = (
+		%defaults,
+		%$environ,
+	);
+	run(["./t/tmp/ikiwiki.cgi"], \$in, \$out, init => sub {
+		map {
+			$ENV{$_} = $envvars{$_}
+		} keys(%envvars);
+	});
+
+	return $out;
+}
+
+sub run_git {
+	my (undef, $filename, $line) = caller;
+	my $args = shift;
+	my $desc = shift || join(' ', 'git', @$args);
+	my ($in, $out);
+	ok(run(['git', @$args], \$in, \$out, init => sub {
+		chdir 't/tmp/in' or die $!;
+		my $name = 'The IkiWiki Tests';
+		my $email = 'nobody@ikiwiki-tests.invalid';
+		if ($args->[0] eq 'commit') {
+			$ENV{GIT_AUTHOR_NAME} = $ENV{GIT_COMMITTER_NAME} = $name;
+			$ENV{GIT_AUTHOR_EMAIL} = $ENV{GIT_COMMITTER_EMAIL} = $email;
+		}
+	}), "$desc at $filename:$line");
+	return $out;
+}
+
+sub test {
+	my $content;
+	my $status;
+
+	ok(! system(qw(rm -rf t/tmp)));
+	ok(! system(qw(mkdir t/tmp)));
+
+	write_old_file('.gitignore', 't/tmp/in', "/doc/.ikiwiki/\n");
+	write_old_file('doc/writable/one.mdwn', 't/tmp/in', 'This is the first test page');
+	write_old_file('doc/writable/two.mdwn', 't/tmp/in', 'This is the second test page');
+	write_old_file('doc/writable/three.mdwn', 't/tmp/in', 'This is the third test page');
+	write_old_file('doc/writable/three.bin', 't/tmp/in', 'An attachment');
+
+	unless ($installed) {
+		ok(! system(qw(cp -pRL doc/wikiicons t/tmp/in/doc/)));
+		ok(! system(qw(cp -pRL doc/recentchanges.mdwn t/tmp/in/doc/)));
+	}
+
+	run_git(['init']);
+	run_git(['add', '.']);
+	run_git(['commit', '-m', 'Initial commit']);
+
+	write_setup_file();
+	thoroughly_rebuild();
+	check_cgi_mode_bits();
+
+	ok(-e 't/tmp/out/writable/one/index.html');
+	$content = readfile('t/tmp/out/writable/one/index.html');
+	like($content, qr{This is the first test page});
+	my $orig_sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']);
+
+	# We have to wait 1 second here so that new writes are guaranteed
+	# to have a strictly larger mtime.
+	sleep 1;
+
+	# Test the git hook, which accepts git commits
+	writefile('doc/writable/one.mdwn', 't/tmp/in',
+		'This is new content for the first test page');
+	run_git(['add', '.']);
+	run_git(['commit', '-m', 'Git commit']);
+	my $first_revertable_sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']);
+	isnt($orig_sha1, $first_revertable_sha1);
+
+	ok(-e 't/tmp/out/writable/one/index.html');
+	$content = readfile('t/tmp/out/writable/one/index.html');
+	like($content, qr{This is new content for the first test page});
+
+	# Test a web commit
+	$content = run_cgi(method => 'POST',
+		params => {
+			do => 'edit',
+			page => 'writable/two',
+			type => 'mdwn',
+			editmessage => 'Web commit',
+			editcontent => 'Here is new content for the second page',
+			_submit => 'Save Page',
+			_submitted => '1',
+		},
+	);
+	like($content, qr{^Status:\s*302\s}m);
+	like($content, qr{^Location:\s*http://example\.com/writable/two/\?updated}m);
+	my $second_revertable_sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']);
+	isnt($orig_sha1, $second_revertable_sha1);
+	isnt($first_revertable_sha1, $second_revertable_sha1);
+
+	ok(-e 't/tmp/out/writable/two/index.html');
+	$content = readfile('t/tmp/out/writable/two/index.html');
+	like($content, qr{Here is new content for the second page});
+
+	# Another edit
+	writefile('doc/writable/three.mdwn', 't/tmp/in',
+		'Also new content for the third page');
+	unlink('t/tmp/in/doc/writable/three.bin');
+	writefile('doc/writable/three.bin', 't/tmp/in',
+		'Changed attachment');
+	run_git(['add', '.']);
+	run_git(['commit', '-m', 'Git commit']);
+	ok(-e 't/tmp/out/writable/three/index.html');
+	$content = readfile('t/tmp/out/writable/three/index.html');
+	like($content, qr{Also new content for the third page});
+	$content = readfile('t/tmp/out/writable/three.bin');
+	like($content, qr{Changed attachment});
+	my $third_revertable_sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']);
+	isnt($orig_sha1, $third_revertable_sha1);
+	isnt($second_revertable_sha1, $third_revertable_sha1);
+
+	run_git(['mv', 'doc/writable/one.mdwn', 'doc/one.mdwn']);
+	run_git(['mv', 'doc/writable/two.mdwn', 'two.mdwn']);
+	run_git(['commit', '-m', 'Rename files to test CVE-2016-10026']);
+	ok(! -e 't/tmp/out/writable/two/index.html');
+	ok(! -e 't/tmp/out/writable/one/index.html');
+	ok(-e 't/tmp/out/one/index.html');
+	my $sha1_before_revert = run_git(['rev-list', '--max-count=1', 'HEAD']);
+	isnt($sha1_before_revert, $third_revertable_sha1);
+
+	$content = run_cgi(method => 'post',
+		params => {
+			do => 'revert',
+			revertmessage => 'CVE-2016-10026',
+			rev => $first_revertable_sha1,
+			_submit => 'Revert',
+			_submitted_revert => '1',
+		},
+	);
+	like($content, qr{is locked and cannot be edited});
+	# The tree is left clean
+	run_git(['diff', '--exit-code']);
+	run_git(['diff', '--cached', '--exit-code']);
+	my $sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']);
+	is($sha1, $sha1_before_revert);
+
+	ok(-e 't/tmp/out/one/index.html');
+	ok(! -e 't/tmp/in/doc/writable/one.mdwn');
+	ok(-e 't/tmp/in/doc/one.mdwn');
+	$content = readfile('t/tmp/out/one/index.html');
+	like($content, qr{This is new content for the first test page});
+
+	$content = run_cgi(method => 'post',
+		params => {
+			do => 'revert',
+			revertmessage => 'CVE-2016-10026',
+			rev => $second_revertable_sha1,
+			_submit => 'Revert',
+			_submitted_revert => '1',
+		},
+	);
+	like($content, qr{you are not allowed to change two\.mdwn});
+	run_git(['diff', '--exit-code']);
+	run_git(['diff', '--cached', '--exit-code']);
+	$sha1 = run_git(['rev-list', '--max-count=1', 'HEAD']);
+	is($sha1, $sha1_before_revert);
+
+	ok(! -e 't/tmp/out/writable/two/index.html');
+	ok(! -e 't/tmp/out/two/index.html');
+	ok(! -e 't/tmp/in/doc/writable/two.mdwn');
+	ok(-e 't/tmp/in/two.mdwn');
+	$content = readfile('t/tmp/in/two.mdwn');
+	like($content, qr{Here is new content for the second page});
+
+	# This one can legitimately be reverted
+	$content = run_cgi(method => 'post',
+		params => {
+			do => 'revert',
+			revertmessage => 'not CVE-2016-10026',
+			rev => $third_revertable_sha1,
+			_submit => 'Revert',
+			_submitted_revert => '1',
+		},
+	);
+	like($content, qr{^Status:\s*302\s}m);
+	like($content, qr{^Location:\s*http://example\.com/recentchanges/}m);
+	run_git(['diff', '--exit-code']);
+	run_git(['diff', '--cached', '--exit-code']);
+	ok(-e 't/tmp/out/writable/three/index.html');
+	$content = readfile('t/tmp/out/writable/three/index.html');
+	like($content, qr{This is the third test page});
+	$content = readfile('t/tmp/out/writable/three.bin');
+	like($content, qr{An attachment});
+}
+
+test();
+
+done_testing();
diff --git a/t/git.t b/t/git.t
index 0396ae065..8990a554e 100755
--- a/t/git.t
+++ b/t/git.t
@@ -27,8 +27,16 @@ $config{diffurl} = '/nonexistent/cgit/plain/[[file]]';
 IkiWiki::loadplugins();
 IkiWiki::checkconfig();
 
+my $makerepo;
+if ($ENV{INSTALLED_TESTS}) {
+	$makerepo = "ikiwiki-makerepo";
+}
+else {
+	$makerepo = "./ikiwiki-makerepo";
+}
+
 ok (mkdir($config{srcdir}));
-is (system("./ikiwiki-makerepo git $config{srcdir} $dir/repo"), 0);
+is (system("$makerepo git $config{srcdir} $dir/repo"), 0);
 
 my @changes;
 @changes = IkiWiki::rcs_recentchanges(3);
diff --git a/t/html.t b/t/html.t
index 84c561fa8..3933ab7ea 100755
--- a/t/html.t
+++ b/t/html.t
@@ -6,6 +6,8 @@ use Test::More;
 my @pages;
 
 BEGIN {
+	plan(skip_all => 'running installed') if $ENV{INSTALLED_TESTS};
+
 	@pages=qw(index features news plugins/map security);
 	if (system("command -v validate >/dev/null") != 0) {
 		plan skip_all => "html validator not present";
diff --git a/t/img.t b/t/img.t
index 537a23d1c..5e92f1aff 100755
--- a/t/img.t
+++ b/t/img.t
@@ -34,6 +34,22 @@ else {
 
 push @command, qw(--set usedirs=0 --plugin img t/tmp/in t/tmp/out --verbose);
 
+my $installed = $ENV{INSTALLED_TESTS};
+
+my @command;
+if ($installed) {
+	@command = qw(ikiwiki);
+}
+else {
+	ok(! system("make -s ikiwiki.out"));
+	@command = qw(perl -I. ./ikiwiki.out
+		--underlaydir=underlays/basewiki
+		--set underlaydirbase=underlays
+		--templatedir=templates);
+}
+
+push @command, qw(--set usedirs=0 --plugin img t/tmp/in t/tmp/out --verbose);
+
 my $magick = new Image::Magick;
 
 $magick->Read("t/img/twopages.pdf");
@@ -44,6 +60,7 @@ ok(! system("rm -rf t/tmp; mkdir -p t/tmp/in"));
 ok(! system("cp t/img/redsquare.png t/tmp/in/redsquare.png"));
 ok(! system("cp t/img/redsquare.jpg t/tmp/in/redsquare.jpg"));
 ok(! system("cp t/img/redsquare.jpg t/tmp/in/redsquare.jpeg"));
+ok(! system("cp t/img/redsquare.jpg t/tmp/in/SHOUTY.JPG"));
 # colons in filenames are a corner case for img
 ok(! system("cp t/img/redsquare.png t/tmp/in/hello:world.png"));
 ok(! system("cp t/img/redsquare.png t/tmp/in/a:b:c.png"));
@@ -58,7 +75,7 @@ ok(! system("cp t/tmp/in/bluesquare.svg t/tmp/in/really-svg.pdf"));
 
 # using different image sizes for different pages, so the pagenumber selection can be tested easily
 ok(! system("cp t/img/twopages.pdf t/tmp/in/twopages.pdf"));
-ok(! system("cp t/img/twopages.pdf t/tmp/in/really-pdf.jpeg"));
+ok(! system("cp t/img/twopages.pdf t/tmp/in/really-pdf.JPEG"));
 ok(! system("cp t/img/twopages.pdf t/tmp/in/really-pdf.jpg"));
 ok(! system("cp t/img/twopages.pdf t/tmp/in/really-pdf.png"));
 ok(! system("cp t/img/twopages.pdf t/tmp/in/really-pdf.svg"));
@@ -75,6 +92,7 @@ writefile("imgconversions.mdwn", "t/tmp/in", <<EOF
 [[!img redsquare.png]]
 [[!img redsquare.jpg size=11x]]
 [[!img redsquare.jpeg size=12x]]
+[[!img SHOUTY.JPG size=13x]]
 [[!img redsquare.png size=10x]]
 [[!img redsquare.png size=30x50]] expecting 30x30
 [[!img hello:world.png size=x8]] expecting 8x8
@@ -89,7 +107,7 @@ $maybe_pdf_img
 [[!img really-svg.png size=666x]]
 [[!img really-svg.bmp size=666x]]
 [[!img really-svg.pdf size=666x]]
-[[!img really-pdf.jpeg size=666x]]
+[[!img really-pdf.JPEG size=666x]]
 [[!img really-pdf.jpg size=666x]]
 [[!img really-pdf.png size=666x]]
 [[!img really-pdf.svg size=666x]]
@@ -133,16 +151,19 @@ is(size("$outpath/x6-a:b:c:d:e:f:g:h:i:j.png"), "6x6");
 
 is(size("$outpath/11x-redsquare.jpg"), "11x11");
 is(size("$outpath/12x-redsquare.jpeg"), "12x12");
+is(size("$outpath/13x-SHOUTY.JPG"), "13x13");
 like($outhtml, qr{src="(\./)?imgconversions/11x-redsquare\.jpg" width="11" height="11"});
 like($outhtml, qr{src="(\./)?imgconversions/12x-redsquare\.jpeg" width="12" height="12"});
+like($outhtml, qr{src="(\./)?imgconversions/13x-SHOUTY\.JPG" width="13" height="13"});
 
 # We do not misinterpret images
 my $quot = qr/(?:"|&quot;)/;
 like($outhtml, qr/${quot}really-svg\.png${quot} does not seem to be a valid png file/);
 ok(! -e "$outpath/666x-really-svg.png");
 ok(! -e "$outpath/666x-really-svg.bmp");
-like($outhtml, qr/${quot}really-pdf\.jpeg${quot} does not seem to be a valid jpeg file/);
+like($outhtml, qr/${quot}really-pdf\.JPEG${quot} does not seem to be a valid jpeg file/);
 ok(! -e "$outpath/666x-really-pdf.jpeg");
+ok(! -e "$outpath/666x-really-pdf.JPEG");
 like($outhtml, qr/${quot}really-pdf\.jpg${quot} does not seem to be a valid jpeg file/);
 ok(! -e "$outpath/666x-really-pdf.jpg");
 like($outhtml, qr/${quot}really-pdf\.png${quot} does not seem to be a valid png file/);
@@ -164,6 +185,7 @@ if (1) { # for easier testing
 	ok(! -e "$outpath/10x-redsquare.png");
 	ok(! -e "$outpath/10x-bluesquare.png");
 	ok(! -e "$outpath/12x-twopages.png");
+	ok(! -e "$outpath/13x-SHOUTY.JPG");
 	ok(! -e "$outpath/16x-p1-twopages.png");
 	ok(! -e "$outpath/x8-hello:world.png");
 	ok(! -e "$outpath/x4-a:b:c.png");
diff --git a/t/inline.t b/t/inline.t
index 726227b8f..536bd6d67 100755
--- a/t/inline.t
+++ b/t/inline.t
@@ -4,6 +4,24 @@ use strict;
 use Test::More;
 use IkiWiki;
 
+my $installed = $ENV{INSTALLED_TESTS};
+
+my @command;
+if ($installed) {
+	@command = qw(ikiwiki);
+}
+else {
+	ok(! system("make -s ikiwiki.out"));
+	@command = qw(perl -I. ./ikiwiki.out
+		--underlaydir=underlays/basewiki
+		--set underlaydirbase=underlays
+		--templatedir=templates);
+}
+
+push @command, qw(--set usedirs=0 --plugin inline
+	--url=http://example.com --cgiurl=http://example.com/ikiwiki.cgi
+	--rss --atom t/tmp/in t/tmp/out --verbose);
+
 my $blob;
 
 ok(! system("rm -rf t/tmp"));
@@ -33,13 +51,8 @@ foreach my $page (qw(protagonists/shepard protagonists/link
 	write_old_file("$page.mdwn", "this page is {$page}");
 }
 
-ok(! system("make -s ikiwiki.out"));
-
-my $command = "perl -I. ./ikiwiki.out -set usedirs=0 -plugin inline -url=http://example.com -cgiurl=http://example.com/ikiwiki.cgi -rss -atom -underlaydir=underlays/basewiki -set underlaydirbase=underlays -templatedir=templates t/tmp/in t/tmp/out -verbose";
-
-ok(! system($command));
-
-ok(! system("$command -refresh"));
+ok(! system(@command));
+ok(! system(@command, "--refresh"));
 
 $blob = readfile("t/tmp/out/protagonists.html");
 like($blob, qr{Add a new post}, 'rootpage=yes gives postform');
diff --git a/t/permalink.t b/t/permalink.t
index 36be984c5..edb05a81b 100755
--- a/t/permalink.t
+++ b/t/permalink.t
@@ -1,14 +1,31 @@
 #!/usr/bin/perl
 use warnings;
 use strict;
-use Test::More 'no_plan';
+use Test::More;
+
+my $installed = $ENV{INSTALLED_TESTS};
+
+my @command;
+if ($installed) {
+	@command = qw(ikiwiki);
+}
+else {
+	ok(! system("make -s ikiwiki.out"));
+	@command = qw(perl -I. ./ikiwiki.out
+		--underlaydir=underlays/basewiki
+		--set underlaydirbase=underlays
+		--templatedir=templates);
+}
 
 ok(! system("rm -rf t/tmp"));
 ok(! system("mkdir t/tmp"));
-ok(! system("make -s ikiwiki.out"));
-ok(! system("perl -I. ./ikiwiki.out -plugin inline -url=http://example.com -cgiurl=http://example.com/ikiwiki.cgi -rss -atom -underlaydir=underlays/basewiki -set underlaydirbase=underlays -templatedir=templates t/tinyblog t/tmp/out"));
+ok(! system(@command, qw(--plugin inline --url=http://example.com
+		--cgiurl=http://example.com/ikiwiki.cgi --rss --atom
+		t/tinyblog t/tmp/out)));
 # This guid should never, ever change, for any reason whatsoever!
 my $guid="http://example.com/post/";;
 ok(length `egrep '<guid.*>$guid</guid>' t/tmp/out/index.rss`);
 ok(length `egrep '<id>$guid</id>' t/tmp/out/index.atom`);
 ok(! system("rm -rf t/tmp t/tinyblog/.ikiwiki"));
+
+done_testing();
diff --git a/t/podcast.t b/t/podcast.t
index 94505a05e..77c905bde 100755
--- a/t/podcast.t
+++ b/t/podcast.t
@@ -9,13 +9,28 @@ BEGIN {
 			"XML::Feed and/or HTML::Parser or File::MimeInfo not available"};
 	}
 	else {
-		eval q{use Test::More tests => 136};
+		eval q{use Test::More tests => 137};
 	}
 }
 
 use Cwd;
 use File::Basename;
 
+my $installed = $ENV{INSTALLED_TESTS};
+
+my @base_command;
+if ($installed) {
+	ok(1, "running installed");
+	@base_command = qw(ikiwiki);
+}
+else {
+	ok(! system("make -s ikiwiki.out"));
+	@base_command = qw(perl -I. ./ikiwiki.out
+		--underlaydir=underlays/basewiki
+		--set underlaydirbase=underlays
+		--templatedir=templates);
+}
+
 my $tmp = 't/tmp';
 my $statedir = 't/tinypodcast/.ikiwiki';
 
@@ -23,10 +38,8 @@ sub podcast {
 	my $podcast_style = shift;
 
 	my $baseurl = 'http://example.com';
-	my @command = (qw(./ikiwiki.out -plugin inline -rss -atom));
-	push @command, qw(-underlaydir=underlays/basewiki);
-	push @command, qw(-set underlaydirbase=underlays -templatedir=templates);
-	push @command, "-url=$baseurl", qw(t/tinypodcast), "$tmp/out";
+	my @command = (@base_command, qw(--plugin inline --rss --atom));
+	push @command, "--url=$baseurl", qw(t/tinypodcast), "$tmp/out";
 
 	ok(! system("mkdir $tmp"),
 		q{setup});
@@ -113,9 +126,7 @@ sub podcast {
 }
 
 sub single_page_html {
-	my @command = (qw(./ikiwiki.out));
-	push @command, qw(-underlaydir=underlays/basewiki);
-	push @command, qw(-set underlaydirbase=underlays -templatedir=templates);
+	my @command = @base_command;
 	push @command, qw(t/tinypodcast), "$tmp/out";
 
 	ok(! system("mkdir $tmp"),
@@ -158,9 +169,7 @@ sub single_page_html {
 }
 
 sub inlined_pages_html {
-	my @command = (qw(./ikiwiki.out -plugin inline));
-	push @command, qw(-underlaydir=underlays/basewiki);
-	push @command, qw(-set underlaydirbase=underlays -templatedir=templates);
+	my @command = (@base_command, qw(--plugin inline));
 	push @command, qw(t/tinypodcast), "$tmp/out";
 
 	ok(! system("mkdir $tmp"),
diff --git a/t/relativity.t b/t/relativity.t
index 054f8f664..73145dfd7 100755
--- a/t/relativity.t
+++ b/t/relativity.t
@@ -16,6 +16,20 @@ use Errno qw(ENOENT);
 
 # Black-box (ish) test for relative linking between CGI and static content
 
+my $installed = $ENV{INSTALLED_TESTS};
+
+my @command;
+if ($installed) {
+	@command = qw(ikiwiki);
+}
+else {
+	ok(! system("make -s ikiwiki.out"));
+	@command = qw(perl -I. ./ikiwiki.out
+		--underlaydir=underlays/basewiki
+		--set underlaydirbase=underlays
+		--templatedir=templates);
+}
+
 sub parse_cgi_content {
 	my $content = shift;
 	my %bits;
@@ -53,7 +67,6 @@ sub write_setup_file {
 wikiname: this is the name of my wiki
 srcdir: t/tmp/in
 destdir: t/tmp/out
-templatedir: templates
 $urlline
 cgiurl: $args{cgiurl}
 $w3mmodeline
@@ -72,7 +85,7 @@ EOF
 
 sub thoroughly_rebuild {
 	ok(unlink("t/tmp/ikiwiki.cgi") || $!{ENOENT});
-	ok(! system("./ikiwiki.out --setup t/tmp/test.setup --rebuild --wrappers"));
+	ok(! system(@command, qw(--setup t/tmp/test.setup --rebuild --wrappers)));
 }
 
 sub check_cgi_mode_bits {
@@ -132,7 +145,6 @@ sub run_cgi {
 }
 
 sub test_startup {
-	ok(! system("make -s ikiwiki.out"));
 	ok(! system("rm -rf t/tmp"));
 	ok(! system("mkdir t/tmp"));
 
diff --git a/t/syntax.t b/t/syntax.t
index b7c6efd58..1d496be2d 100755
--- a/t/syntax.t
+++ b/t/syntax.t
@@ -3,6 +3,8 @@ use warnings;
 use strict;
 use Test::More;
 
+plan(skip_all => 'running installed') if $ENV{INSTALLED_TESTS};
+
 my @progs="ikiwiki.in";
 my @libs="IkiWiki.pm";
 # monotone, external, amazon_s3, po, and cvs
diff --git a/t/template_syntax.t b/t/template_syntax.t
index e3d1feca9..3e6509f35 100755
--- a/t/template_syntax.t
+++ b/t/template_syntax.t
@@ -3,6 +3,8 @@ use warnings;
 use strict;
 use Test::More;
 
+plan(skip_all => 'running installed') if $ENV{INSTALLED_TESTS};
+
 my @templates=(glob("templates/*.tmpl"), glob("doc/templates/*.mdwn"));
 plan(tests => 2*@templates);
 
diff --git a/t/templates_documented.t b/t/templates_documented.t
index 826c51d36..4991e4521 100755
--- a/t/templates_documented.t
+++ b/t/templates_documented.t
@@ -1,7 +1,9 @@
 #!/usr/bin/perl
 use warnings;
 use strict;
-use Test::More 'no_plan';
+use Test::More;
+
+plan(skip_all => 'running installed') if $ENV{INSTALLED_TESTS};
 
 $/=undef;
 open(IN, "doc/templates.mdwn") || die "doc/templates.mdwn: $!";
@@ -12,3 +14,5 @@ foreach my $file (glob("templates/*.tmpl")) {
 	$file=~s/templates\///;
 	ok($page =~ /\Q$file\E/, "$file documented on doc/templates.mdwn");
 }
+
+done_testing();
diff --git a/t/trail.t b/t/trail.t
index dce3b3c7e..cac64c366 100755
--- a/t/trail.t
+++ b/t/trail.t
@@ -1,7 +1,7 @@
 #!/usr/bin/perl
 use warnings;
 use strict;
-use Test::More 'no_plan';
+use Test::More;
 use IkiWiki;
 
 sub check_trail {
@@ -27,6 +27,24 @@ my $blob;
 ok(! system("rm -rf t/tmp"));
 ok(! system("mkdir t/tmp"));
 
+my $installed = $ENV{INSTALLED_TESTS};
+
+my @command;
+if ($installed) {
+	@command = qw(ikiwiki);
+}
+else {
+	ok(! system("make -s ikiwiki.out"));
+	@command = qw(perl -I. ./ikiwiki.out
+		--underlaydir=underlays/basewiki
+		--set underlaydirbase=underlays
+		--templatedir=templates);
+}
+
+push @command, qw(--set usedirs=0 --plugin trail --plugin inline
+	--url=http://example.com --cgiurl=http://example.com/ikiwiki.cgi
+	--rss --atom t/tmp/in t/tmp/out --verbose);
+
 # Write files with a date in the past, so that when we refresh,
 # the update is detected.
 sub write_old_file {
@@ -129,13 +147,8 @@ write_old_file("wind_in_the_willows.mdwn", <<EOF
 EOF
 );
 
-ok(! system("make -s ikiwiki.out"));
-
-my $command = "perl -I. ./ikiwiki.out -set usedirs=0 -plugin trail -plugin inline -url=http://example.com -cgiurl=http://example.com/ikiwiki.cgi -rss -atom -underlaydir=underlays/basewiki -set underlaydirbase=underlays -templatedir=templates t/tmp/in t/tmp/out -verbose";
-
-ok(! system($command));
-
-ok(! system("$command -refresh"));
+ok(! system(@command));
+ok(! system(@command, "--refresh"));
 
 $blob = readfile("t/tmp/out/meme.html");
 ok($blob =~ /<a href="(\.\/)?badger.html">badger<\/a>/m);
@@ -232,7 +245,7 @@ writefile("limited/c.mdwn", "t/tmp/in", '[[!meta title="New C page"]]c');
 
 writefile("untrail.mdwn", "t/tmp/in", "no longer a trail");
 
-ok(! system("$command -refresh"));
+ok(! system(@command, "--refresh"));
 
 check_trail("add/a.html", "n=add/b p=");
 check_trail("add/b.html", "n=add/c p=add/a");
@@ -290,3 +303,5 @@ check_no_trail("untrail/a.html");
 check_no_trail("untrail/b.html");
 
 ok(! system("rm -rf t/tmp"));
+
+done_testing();

--- End Message ---
--- Begin Message ---
Control: tags 850830 + wontfix

On Tue, 10 Jan 2017 at 15:28:08 +0000, Simon McVittie wrote:
> I recently fixed some minor security vulnerabilities in ikiwiki, for
> which the Security Team declined to issue a DSA. I'd like to get their
> fixes into stable.

While reviewing the proposed changes I found another, more serious
security vulnerability which does merit a DSA, so this is now going
through the security.debian.org pipeline and is no longer relevant
to the SRMs.

Sorry for the noise,
    S

--- End Message ---

Reply to: