diff --git a/_test/tests/inc/io_deletefromfile.test.php b/_test/tests/inc/io_deletefromfile.test.php
new file mode 100644
index 0000000000000000000000000000000000000000..e86150aac8a3864f0aca40ec266fd43ce77778ae
--- /dev/null
+++ b/_test/tests/inc/io_deletefromfile.test.php
@@ -0,0 +1,15 @@
+<?php
+
+class io_deletefromfile_test extends DokuWikiTest {
+
+    function test_delete(){
+        $file = TMP_DIR.'/test.txt';
+        $contents = "The\012Delete\012Delete01\012Delete02\012Delete\012DeleteX\012Test\012";
+        io_saveFile($file, $contents);
+        $this->assertTrue(io_deleteFromFile($file, "Delete\012"));
+        $this->assertEquals("The\012Delete01\012Delete02\012DeleteX\012Test\012", io_readFile($file));
+        $this->assertTrue(io_deleteFromFile($file, "#Delete\\d+\012#", true));
+        $this->assertEquals("The\012DeleteX\012Test\012", io_readFile($file));
+    }
+
+}
diff --git a/_test/tests/inc/io_readfile.test.php b/_test/tests/inc/io_readfile.test.php
index e3e90cd8d880a03618aa3a0aa5bb92154500b5fa..700c1902b23c1f4b7502ae26fe66f5d89baf641d 100644
--- a/_test/tests/inc/io_readfile.test.php
+++ b/_test/tests/inc/io_readfile.test.php
@@ -48,6 +48,11 @@ class io_readfile_test extends DokuWikiTest {
         $this->assertEquals("The\015\012Test\015\012", io_readFile(__DIR__.'/io_readfile/test.txt.bz2', false));
         $this->assertEquals(false, io_readFile(__DIR__.'/io_readfile/nope.txt.bz2'));
         $this->assertEquals(false, io_readFile(__DIR__.'/io_readfile/corrupt.txt.bz2'));
+        // internal bzfile function
+        $this->assertEquals(array("The\015\012","Test\015\012"), bzfile(__DIR__.'/io_readfile/test.txt.bz2', true));
+        $this->assertEquals(array_fill(0, 120, str_repeat('a', 80)."\012"), bzfile(__DIR__.'/io_readfile/large.txt.bz2', true));
+        $line = str_repeat('a', 8888)."\012";
+        $this->assertEquals(array($line,"\012",$line,"!"), bzfile(__DIR__.'/io_readfile/long.txt.bz2', true));
     }
 
-}
\ No newline at end of file
+}
diff --git a/_test/tests/inc/io_readfile/large.txt.bz2 b/_test/tests/inc/io_readfile/large.txt.bz2
new file mode 100644
index 0000000000000000000000000000000000000000..3135435f82ed3e14ecd60b87af5b9d16f247f45a
Binary files /dev/null and b/_test/tests/inc/io_readfile/large.txt.bz2 differ
diff --git a/_test/tests/inc/io_readfile/long.txt.bz2 b/_test/tests/inc/io_readfile/long.txt.bz2
new file mode 100644
index 0000000000000000000000000000000000000000..fb40759e618fa0c589f4a787e11fc48b6d7c75e4
Binary files /dev/null and b/_test/tests/inc/io_readfile/long.txt.bz2 differ
diff --git a/_test/tests/inc/io_replaceinfile.test.php b/_test/tests/inc/io_replaceinfile.test.php
new file mode 100644
index 0000000000000000000000000000000000000000..452ed740108fe48cf0351f358a466ab1796487fd
--- /dev/null
+++ b/_test/tests/inc/io_replaceinfile.test.php
@@ -0,0 +1,108 @@
+<?php
+
+class io_replaceinfile_test extends DokuWikiTest {
+
+    protected $contents = "The\012Delete\012Delete\012Delete01\012Delete02\012Delete\012DeleteX\012Test\012";
+
+    /*
+     * dependency for tests needing zlib extension to pass
+     */
+    public function test_ext_zlib() {
+        if (!extension_loaded('zlib')) {
+            $this->markTestSkipped('skipping all zlib tests.  Need zlib extension');
+        }
+    }
+
+    /*
+     * dependency for tests needing zlib extension to pass
+     */
+    public function test_ext_bz2() {
+        if (!extension_loaded('bz2')) {
+            $this->markTestSkipped('skipping all bzip2 tests.  Need bz2 extension');
+        }
+    }
+
+    function _write($file){
+
+        io_saveFile($file, $this->contents);
+        // Replace one, no regex
+        $this->assertTrue(io_replaceInFile($file, "Delete\012", "Delete00\012", false, 1));
+        $this->assertEquals("The\012Delete00\012Delete\012Delete01\012Delete02\012Delete\012DeleteX\012Test\012", io_readFile($file));
+        // Replace all, no regex
+        $this->assertTrue(io_replaceInFile($file, "Delete\012", "DeleteX\012", false, -1));
+        $this->assertEquals("The\012Delete00\012DeleteX\012Delete01\012Delete02\012DeleteX\012DeleteX\012Test\012", io_readFile($file));
+        // Replace two, regex and backreference
+        $this->assertTrue(io_replaceInFile($file, "#Delete(\\d+)\012#", "\\1\012", true, 2));
+        $this->assertEquals("The\01200\012DeleteX\01201\012Delete02\012DeleteX\012DeleteX\012Test\012", io_readFile($file));
+        // Delete and insert, no regex
+        $this->assertTrue(io_replaceInFile($file, "DeleteX\012", "Replace\012", false, 0));
+        $this->assertEquals("The\01200\01201\012Delete02\012Test\012Replace\012", io_readFile($file));
+    }
+
+    function test_replace(){
+        $this->_write(TMP_DIR.'/test.txt');
+    }
+
+
+    /**
+     * @depends test_ext_zlib
+     */
+    function test_gzwrite(){
+        $this->_write(TMP_DIR.'/test.txt.gz');
+    }
+
+    /**
+     * @depends test_ext_bz2
+     */
+    function test_bzwrite(){
+        $this->_write(TMP_DIR.'/test.txt.bz2');
+    }
+
+    /**
+     * Test for a non-regex replacement where $newline contains a backreference like construct - it shouldn't affect the replacement
+     */
+    function test_edgecase1()
+    {
+        $file = TMP_DIR . '/test.txt';
+
+        io_saveFile($file, $this->contents);
+        $this->assertTrue(io_replaceInFile($file, "Delete\012", "Delete\\00\012", false, -1));
+        $this->assertEquals("The\012Delete\\00\012Delete\\00\012Delete01\012Delete02\012Delete\\00\012DeleteX\012Test\012", io_readFile($file), "Edge case: backreference like construct in replacement line");
+    }
+    /**
+     * Test with replace all where replacement line == search line - must not timeout
+     *
+     * @small
+     */
+    function test_edgecase2() {
+        $file = TMP_DIR.'/test.txt';
+
+        io_saveFile($file, $this->contents);
+        $this->assertTrue(io_replaceInFile($file, "Delete\012", "Delete\012", false, -1));
+        $this->assertEquals("The\012Delete\012Delete\012Delete01\012Delete02\012Delete\012DeleteX\012Test\012", io_readFile($file), "Edge case: new line the same as old line");
+    }
+
+    /**
+     *    Test where $oldline exactly matches one line and also matches part of other lines - only the exact match should be replaced
+     */
+    function test_edgecase3()
+    {
+        $file = TMP_DIR . '/test.txt';
+        $contents = "The\012Delete\01201Delete\01202Delete\012Test\012";
+
+        io_saveFile($file, $contents);
+        $this->assertTrue(io_replaceInFile($file, "Delete\012", "Replace\012", false, -1));
+        $this->assertEquals("The\012Replace\01201Delete\01202Delete\012Test\012", io_readFile($file), "Edge case: old line is a match for parts of other lines");
+    }
+
+    /**
+     * Test passing an invalid parameter.
+     *
+     * @expectedException PHPUnit_Framework_Error_Warning
+     */
+    function test_badparam()
+    {
+        /* The empty $oldline parameter should be caught before the file doesn't exist test. */
+        $this->assertFalse(io_replaceInFile(TMP_DIR.'/not_existing_file.txt', '', '', false, 0));
+    }
+}
diff --git a/_test/tests/inc/io_savefile.test.php b/_test/tests/inc/io_savefile.test.php
new file mode 100644
index 0000000000000000000000000000000000000000..4a4d4671d2d750b8197ba83a30e5164db1bf2c51
--- /dev/null
+++ b/_test/tests/inc/io_savefile.test.php
@@ -0,0 +1,49 @@
+<?php
+
+class io_savefile_test extends DokuWikiTest {
+
+    /*
+     * dependency for tests needing zlib extension to pass
+     */
+    public function test_ext_zlib() {
+        if (!extension_loaded('zlib')) {
+            $this->markTestSkipped('skipping all zlib tests.  Need zlib extension');
+        }
+    }
+
+    /*
+     * dependency for tests needing zlib extension to pass
+     */
+    public function test_ext_bz2() {
+        if (!extension_loaded('bz2')) {
+            $this->markTestSkipped('skipping all bzip2 tests.  Need bz2 extension');
+        }
+    }
+
+    function _write($file){
+        $contents = "The\012Write\012Test\012";
+        $this->assertTrue(io_saveFile($file, $contents));
+        $this->assertEquals($contents, io_readFile($file));
+        $this->assertTrue(io_saveFile($file, $contents, true));
+        $this->assertEquals($contents.$contents, io_readFile($file));
+    }
+
+    function test_write(){
+        $this->_write(TMP_DIR.'/test.txt');
+    }
+
+    /**
+     * @depends test_ext_zlib
+     */
+    function test_gzwrite(){
+        $this->_write(TMP_DIR.'/test.txt.gz');
+    }
+
+    /**
+     * @depends test_ext_bz2
+     */
+    function test_bzwrite(){
+        $this->_write(TMP_DIR.'/test.txt.bz2');
+    }
+
+}
diff --git a/inc/io.php b/inc/io.php
index 0636a4b62503c76a7fd76b15f458acdcdc5e1f7d..9be64882478b5c6625b7637cf65f0c334dfe2b89 100644
--- a/inc/io.php
+++ b/inc/io.php
@@ -127,22 +127,36 @@ function io_readFile($file,$clean=true){
  * @author  Andreas Gohr <andi@splitbrain.org>
  *
  * @param string $file filename
- * @return string|bool content or false on error
+ * @param bool   $array return array of lines
+ * @return string|array|bool content or false on error
  */
-function bzfile($file){
+function bzfile($file, $array=false) {
     $bz = bzopen($file,"r");
     if($bz === false) return false;
 
+    if($array) $lines = array();
     $str = '';
-    while (!feof($bz)){
+    while (!feof($bz)) {
         //8192 seems to be the maximum buffersize?
         $buffer = bzread($bz,8192);
         if(($buffer === false) || (bzerrno($bz) !== 0)) {
             return false;
         }
         $str = $str . $buffer;
+        if($array) {
+            $pos = strpos($str, "\n");
+            while($pos !== false) {
+                $lines[] = substr($str, 0, $pos+1);
+                $str = substr($str, $pos+1);
+                $pos = strpos($str, "\n");
+            }
+        }
     }
     bzclose($bz);
+    if($array) {
+        if($str !== '') $lines[] = $str;
+        return $lines;
+    }
     return $str;
 }
 
@@ -191,13 +205,7 @@ function _io_writeWikiPage_action($data) {
 }
 
 /**
- * Saves $content to $file.
- *
- * If the third parameter is set to true the given content
- * will be appended.
- *
- * Uses gzip if extension is .gz
- * and bz2 if extension is .bz2
+ * Internal function to save contents to a file.
  *
  * @author  Andreas Gohr <andi@splitbrain.org>
  *
@@ -206,64 +214,97 @@ function _io_writeWikiPage_action($data) {
  * @param bool   $append
  * @return bool true on success, otherwise false
  */
-function io_saveFile($file,$content,$append=false){
+function _io_saveFile($file, $content, $append) {
     global $conf;
     $mode = ($append) ? 'ab' : 'wb';
-
     $fileexists = file_exists($file);
-    io_makeFileDir($file);
-    io_lock($file);
+
     if(substr($file,-3) == '.gz'){
         $fh = @gzopen($file,$mode.'9');
-        if(!$fh){
-            msg("Writing $file failed",-1);
-            io_unlock($file);
-            return false;
-        }
+        if(!$fh) return false;
         gzwrite($fh, $content);
         gzclose($fh);
     }else if(substr($file,-4) == '.bz2'){
-        $fh = @bzopen($file,$mode{0});
-        if(!$fh){
-            msg("Writing $file failed", -1);
-            io_unlock($file);
-            return false;
+        if($append) {
+            $bzcontent = bzfile($file);
+            if($bzcontent === false) return false;
+            $content = $bzcontent.$content;
         }
+        $fh = @bzopen($file,'w');
+        if(!$fh) return false;
         bzwrite($fh, $content);
         bzclose($fh);
     }else{
         $fh = @fopen($file,$mode);
-        if(!$fh){
-            msg("Writing $file failed",-1);
-            io_unlock($file);
-            return false;
-        }
+        if(!$fh) return false;
         fwrite($fh, $content);
         fclose($fh);
     }
 
     if(!$fileexists and !empty($conf['fperm'])) chmod($file, $conf['fperm']);
-    io_unlock($file);
     return true;
 }
 
 /**
- * Delete exact linematch for $badline from $file.
+ * Saves $content to $file.
  *
- * Be sure to include the trailing newline in $badline
+ * If the third parameter is set to true the given content
+ * will be appended.
  *
  * Uses gzip if extension is .gz
+ * and bz2 if extension is .bz2
  *
- * 2005-10-14 : added regex option -- Christopher Smith <chris@jalakai.co.uk>
+ * @author  Andreas Gohr <andi@splitbrain.org>
  *
- * @author Steven Danz <steven-danz@kc.rr.com>
+ * @param string $file filename path to file
+ * @param string $content
+ * @param bool   $append
+ * @return bool true on success, otherwise false
+ */
+function io_saveFile($file, $content, $append=false) {
+    io_makeFileDir($file);
+    io_lock($file);
+    if(!_io_saveFile($file, $content, $append)) {
+        msg("Writing $file failed",-1);
+        io_unlock($file);
+        return false;
+    }
+    io_unlock($file);
+    return true;
+}
+
+/**
+ * Replace one or more occurrences of a line in a file.
  *
- * @param string $file    filename
- * @param string $badline exact linematch to remove
- * @param bool   $regex   use regexp?
+ * The default, when $maxlines is 0 is to delete all matching lines then append a single line.
+ * A regex that matches any part of the line will remove the entire line in this mode.
+ * Captures in $newline are not available.
+ *
+ * Otherwise each line is matched and replaced individually, up to the first $maxlines lines
+ * or all lines if $maxlines is -1. If $regex is true then captures can be used in $newline.
+ *
+ * Be sure to include the trailing newline in $oldline when replacing entire lines.
+ *
+ * Uses gzip if extension is .gz
+ * and bz2 if extension is .bz2
+ *
+ * @author Steven Danz <steven-danz@kc.rr.com>
+ * @author Christopher Smith <chris@jalakai.co.uk>
+ * @author Patrick Brown <ptbrown@whoopdedo.org>
+ *
+ * @param string $file     filename
+ * @param string $oldline  exact linematch to remove
+ * @param string $newline  new line to insert
+ * @param bool   $regex    use regexp?
+ * @param int    $maxlines number of occurrences of the line to replace
  * @return bool true on success
  */
-function io_deleteFromFile($file,$badline,$regex=false){
+function io_replaceInFile($file, $oldline, $newline, $regex=false, $maxlines=0) {
+    if ((string)$oldline === '') {
+        trigger_error('$oldline parameter cannot be empty in io_replaceInFile()', E_USER_WARNING);
+        return false;
+    }
+
     if (!file_exists($file)) return true;
 
     io_lock($file);
@@ -271,41 +312,40 @@ function io_deleteFromFile($file,$badline,$regex=false){
     // load into array
     if(substr($file,-3) == '.gz'){
         $lines = gzfile($file);
+    }else if(substr($file,-4) == '.bz2'){
+        $lines = bzfile($file, true);
     }else{
         $lines = file($file);
     }
 
-    // remove all matching lines
-    if ($regex) {
-        $lines = preg_grep($badline,$lines,PREG_GREP_INVERT);
-    } else {
-        $pos = array_search($badline,$lines); //return null or false if not found
-        while(is_int($pos)){
-            unset($lines[$pos]);
-            $pos = array_search($badline,$lines);
+    // make non-regexes into regexes
+    $pattern = $regex ? $oldline : '/^'.preg_quote($oldline,'/').'$/';
+    $replace = $regex ? $newline : addcslashes($newline, '\$');
+
+    // remove matching lines
+    if ($maxlines > 0) {
+        $count = 0;
+        $matched = 0;
+        while (($count < $maxlines) && (list($i,$line) = each($lines))) {
+            // $matched will be set to 0|1 depending on whether pattern is matched and line replaced
+            $lines[$i] = preg_replace($pattern, $replace, $line, -1, $matched);
+            if ($matched) $count++;
+        }
+    } else if ($maxlines == 0) {
+        $lines = preg_grep($pattern, $lines, PREG_GREP_INVERT);
+
+        if ((string)$newline !== ''){
+            $lines[] = $newline;
         }
+    } else {
+        $lines = preg_replace($pattern, $replace, $lines);
     }
 
     if(count($lines)){
-        $content = join('',$lines);
-        if(substr($file,-3) == '.gz'){
-            $fh = @gzopen($file,'wb9');
-            if(!$fh){
-                msg("Removing content from $file failed",-1);
-                io_unlock($file);
-                return false;
-            }
-            gzwrite($fh, $content);
-            gzclose($fh);
-        }else{
-            $fh = @fopen($file,'wb');
-            if(!$fh){
-                msg("Removing content from $file failed",-1);
-                io_unlock($file);
-                return false;
-            }
-            fwrite($fh, $content);
-            fclose($fh);
+        if(!_io_saveFile($file, join('',$lines), false)) {
+            msg("Removing content from $file failed",-1);
+            io_unlock($file);
+            return false;
         }
     }else{
         @unlink($file);
@@ -315,6 +355,22 @@ function io_deleteFromFile($file,$badline,$regex=false){
     return true;
 }
 
+/**
+ * Delete lines that match $badline from $file.
+ *
+ * Be sure to include the trailing newline in $badline
+ *
+ * @author Patrick Brown <ptbrown@whoopdedo.org>
+ *
+ * @param string $file    filename
+ * @param string $badline exact linematch to remove
+ * @param bool   $regex   use regexp?
+ * @return bool true on success
+ */
+function io_deleteFromFile($file,$badline,$regex=false){
+    return io_replaceInFile($file,$badline,null,$regex,0);
+}
+
 /**
  * Tries to lock a file
  *
diff --git a/lib/plugins/acl/admin.php b/lib/plugins/acl/admin.php
index 3742057698e9613135449ea76dad649e91829764..f4baec9941aeca5aaad3ec70eb4ac10c05cc5403 100644
--- a/lib/plugins/acl/admin.php
+++ b/lib/plugins/acl/admin.php
@@ -682,7 +682,6 @@ class admin_plugin_acl extends DokuWiki_Admin_Plugin {
      */
     function _acl_add($acl_scope, $acl_user, $acl_level){
         global $config_cascade;
-        $acl_config = file_get_contents($config_cascade['acl']['default']);
         $acl_user = auth_nameencode($acl_user,true);
 
         // max level for pagenames is edit
@@ -692,9 +691,7 @@ class admin_plugin_acl extends DokuWiki_Admin_Plugin {
 
         $new_acl = "$acl_scope\t$acl_user\t$acl_level\n";
 
-        $new_config = $acl_config.$new_acl;
-
-        return io_saveFile($config_cascade['acl']['default'], $new_config);
+        return io_saveFile($config_cascade['acl']['default'], $new_acl, true);
     }
 
     /**
@@ -704,15 +701,11 @@ class admin_plugin_acl extends DokuWiki_Admin_Plugin {
      */
     function _acl_del($acl_scope, $acl_user){
         global $config_cascade;
-        $acl_config = file($config_cascade['acl']['default']);
         $acl_user = auth_nameencode($acl_user,true);
 
         $acl_pattern = '^'.preg_quote($acl_scope,'/').'[ \t]+'.$acl_user.'[ \t]+[0-8].*$';
 
-        // save all non!-matching
-        $new_config = preg_grep("/$acl_pattern/", $acl_config, PREG_GREP_INVERT);
-
-        return io_saveFile($config_cascade['acl']['default'], join('',$new_config));
+        return io_deleteFromFile($config_cascade['acl']['default'], "/$acl_pattern/", true);
     }
 
     /**
diff --git a/lib/plugins/authplain/auth.php b/lib/plugins/authplain/auth.php
index bd46c61a7d10c3b51cdc2370eb2e4cda58638351..8ec632dada62424a5a847ce3c36d9bd9833ae6e9 100644
--- a/lib/plugins/authplain/auth.php
+++ b/lib/plugins/authplain/auth.php
@@ -188,15 +188,9 @@ class auth_plugin_authplain extends DokuWiki_Auth_Plugin {
 
         $userline = $this->_createUserLine($newuser, $userinfo['pass'], $userinfo['name'], $userinfo['mail'], $userinfo['grps']);
 
-        if(!$this->deleteUsers(array($user))) {
-            msg($this->getLang('writefail'), -1);
-            return false;
-        }
-
-        if(!io_saveFile($config_cascade['plainauth.users']['default'], $userline, true)) {
-            msg('There was an error modifying your user data. You should register again.', -1);
-            // FIXME, user has been deleted but not recreated, should force a logout and redirect to login page
-            // Should replace the delete/save hybrid modify with an atomic io_replaceInFile
+        if(!io_replaceInFile($config_cascade['plainauth.users']['default'], '/^'.$user.':/', $userline, true)) {
+            msg('There was an error modifying your user data. You may need to register again.', -1);
+            // FIXME, io functions should be fail-safe so existing data isn't lost
             $ACT = 'register';
             return false;
         }