=== modified file 'includes/menu.inc'
--- includes/menu.inc	2009-05-14 08:35:58 +0000
+++ includes/menu.inc	2009-05-23 03:41:37 +0000
@@ -632,6 +632,10 @@ function _menu_translate(&$router_item, 
     }
   }
   $router_item['href'] = implode('/', $link_map);
+  if (!preg_match($router_item['mask'], $router_item['href'])) {
+    $router_item['access'] = FALSE;
+    return FALSE;
+  }
   $router_item['options'] = array();
   _menu_check_access($router_item, $map);
 
@@ -702,8 +706,9 @@ function _menu_link_translate(&$item) {
     _menu_link_map_translate($map, $item['to_arg_functions']);
     $item['href'] = implode('/', $map);
 
-    // Note - skip callbacks without real values for their arguments.
-    if (strpos($item['href'], '%') !== FALSE) {
+    // Note - skip callbacks without real values for their arguments and links
+    // that do not validate.
+    if (strpos($item['href'], '%') !== FALSE || !preg_match($item['mask'], $item['href'])) {
       $item['access'] = FALSE;
       return FALSE;
     }
@@ -879,6 +884,7 @@ function menu_tree_all_data($menu_name, 
         'title_arguments',
         'type',
         'description',
+        'mask',
       ));
       for ($i = 1; $i <= MENU_MAX_DEPTH; $i++) {
         $query->orderBy('p' . $i, 'ASC');
@@ -1049,6 +1055,7 @@ function menu_tree_page_data($menu_name)
           'title_arguments',
           'type',
           'description',
+          'mask',
         ));
         for ($i = 1; $i <= MENU_MAX_DEPTH; $i++) {
           $query->orderBy('p' . $i, 'ASC');
@@ -2565,13 +2572,13 @@ function _menu_router_build($callbacks) 
   }
   array_multisort($sort, SORT_NUMERIC, $menu);
   // Apply inheritance rules.
-  foreach ($menu as $path => $v) {
-    $item = &$menu[$path];
+  foreach ($menu as $path => &$item) {
     if (!$item['_tab']) {
       // Non-tab items.
       $item['tab_parent'] = '';
       $item['tab_root'] = $path;
     }
+    $item['mask'] = _menu_build_mask($item, $path);
     for ($i = $item['_number_parts'] - 1; $i; $i--) {
       $parent_path = implode('/', array_slice($item['_parts'], 0, $i));
       if (isset($menu[$parent_path])) {
@@ -2666,6 +2673,7 @@ function _menu_router_save($menu, $masks
       'description',
       'position',
       'weight',
+      'mask',
     ));
 
   foreach ($menu as $path => $item) {
@@ -2690,6 +2698,7 @@ function _menu_router_save($menu, $masks
       'description' => $item['description'],
       'position' => $item['position'],
       'weight' => $item['weight'],
+      'mask' => $item['mask'],
     ));
   }
   // Execute insert object.
@@ -2701,6 +2710,72 @@ function _menu_router_save($menu, $masks
 }
 
 /**
+ * Build the mask for a menu item based on its definition.
+ *
+ * @param $item
+ *   The menu item to determine the mask of, as an array.
+ * @param $path
+ *   The path of the current menu item, e.g. node/%.
+ * @return
+ *   The string mask.
+ */
+function _menu_build_mask($item, $path) {
+  if (isset($item['mask'])) {
+    if (isset($item['mask']['rest'])) {
+      $rest = $item['mask']['rest'];
+      unset($item['mask']['rest']);
+    }
+    // If the mask is only for a wildcard inside the router path, we still
+    // need to fill the mask to covert all router parts.
+    $mask_length = $item['_number_parts'] - 1;
+    if (count($item['mask'])) {
+      $mask_length = max($mask_length, max(array_keys($item['mask'])));
+    }
+    $suffix = '';
+    $mask = '';
+    $parts = $item['_parts'];
+    for ($i = 0; $i <= $mask_length; $i++) {
+      $fallback = isset($parts[$i]) ? preg_quote($parts[$i], '#') : '';
+      $mask_part = isset($item['mask'][$i]) ? $item['mask'][$i] : $fallback;
+      _menu_build_mask_part($mask_part);
+      if (isset($parts[$i])) {
+        $prefix = $i ? '/' : '';
+      }
+      else {
+        // If we are past the last router path part then all the parts are
+        // optional, so we wrap them in conditional non grouping blocks.
+        $prefix = '(?:/';
+        $suffix .= ')?';
+      }
+      $mask .= $prefix . $mask_part;
+    }
+    $mask = '#^'. $mask;
+    if (isset($rest)) {
+      _menu_build_mask_part($rest);
+      $mask .= "(?:/$rest)*";
+    }
+    $mask .= $suffix . '$#';
+  }
+  else {
+    $mask = '#^'. str_replace('%', '\d+', preg_quote($path, '#')) . '$#';
+  }
+  return $mask;
+}
+
+function _menu_build_mask_part(&$mask_part) {
+  static $mask_map = array('*' => '[^/]+', '%' => '\d+', '%d' => '\d+', '%c' => ' \w+');
+  if (isset($mask_map[$mask_part])) {
+    $mask_part = $mask_map[$mask_part];
+  }
+  elseif (preg_match('/^%([a-z_]+)$/', $mask_part, $matches)) {
+    $constant = strtoupper($matches[1]) . '_MASK';
+    if (defined($constant)) {
+      $mask_part = constant($constant);
+    }
+  }
+}
+
+/**
  * Returns TRUE if a path is external (e.g. http://example.com).
  */
 function menu_path_is_external($path) {

=== modified file 'modules/book/book.module'
--- modules/book/book.module	2009-05-09 18:28:11 +0000
+++ modules/book/book.module	2009-05-23 03:48:17 +0000
@@ -1168,7 +1168,7 @@ function book_menu_subtree_data($item) {
       $menu_router_alias = $query->join('menu_router', 'm', 'm.path = ml.router_path');
       $book_alias = $query->join('book', 'b', 'ml.mlid = b.mlid');
       $query->fields($book_alias);
-      $query->fields($menu_router_alias, array('load_functions', 'to_arg_functions', 'access_callback', 'access_arguments', 'page_callback', 'page_arguments', 'title', 'title_callback', 'title_arguments', 'type'));
+      $query->fields($menu_router_alias, array('load_functions', 'to_arg_functions', 'access_callback', 'access_arguments', 'page_callback', 'page_arguments', 'title', 'title_callback', 'title_arguments', 'type', 'mask'));
       $query->fields('ml');
       $query->condition('menu_name', $item['menu_name']);
       for ($i = 1; $i <= MENU_MAX_DEPTH && $item["p$i"]; ++$i) {

=== modified file 'modules/search/search.module'
--- modules/search/search.module	2009-05-16 16:06:04 +0000
+++ modules/search/search.module	2009-05-23 03:41:50 +0000
@@ -223,6 +223,7 @@ function search_menu() {
       'access arguments' => array($module),
       'type' => MENU_LOCAL_TASK,
       'parent' => 'search',
+      'mask' => array('2' => '*', 'rest' => '*'),
     );
   }
   return $items;

=== modified file 'modules/simpletest/simpletest.module'
--- modules/simpletest/simpletest.module	2009-05-17 10:49:40 +0000
+++ modules/simpletest/simpletest.module	2009-05-23 03:29:20 +0000
@@ -47,6 +47,7 @@ function simpletest_menu() {
     'description' => 'View result of tests.',
     'access arguments' => array('administer unit tests'),
     'type' => MENU_CALLBACK,
+    'mask' => array(4 => '\d+|re-run')
   );
   return $items;
 }

=== modified file 'modules/simpletest/tests/menu.test'
--- modules/simpletest/tests/menu.test	2009-05-17 07:49:13 +0000
+++ modules/simpletest/tests/menu.test	2009-05-23 00:19:26 +0000
@@ -162,3 +162,103 @@ class MenuRebuildTestCase extends Drupal
   }
 
 }
+
+class MenuMasksTestCase extends DrupalWebTestCase {
+
+  /**
+   * Implementation of getInfo().
+   */
+  function getInfo() {
+    return array(
+      'name' => t('Menu masks'),
+      'description' => t('Tests the menu item masking functionality, making sure it works as expected in all cases.'),
+      // Don't use 'Menu' as the group in order to keep it separate from the
+      // menu module tests.
+      'group' => t('Menu system'),
+    );
+  }
+
+  /**
+   * Tests a set of masks to make sure they work properly.
+   */
+  function testMenuMasks() {
+    // This constant is used for the %testing_test placeholder in tests 8 - 12.
+    define('TESTING_TEST_MASK', '(test|testing)');
+
+    $items = array();
+
+    $items['testing'] = array(
+      'expected' => '#^testing$#',
+    );
+
+    $items['test#ing2'] = array(
+      'expected' => '#^test\#ing2$#',
+    );
+
+    $items['testing3/%'] = array(
+      'expected' => '#^testing3/\d+$#',
+    );
+
+    $items['testing4/%'] = array(
+      'mask' => array(1 => '*'),
+      'expected' => '#^testing4/[^/]+$#',
+    );
+
+    $items['testing5/%'] = array(
+      'mask' => array(1 => '%d'),
+      'expected' => '#^testing5/\d+$#',
+    );
+
+    $items['testing6/%'] = array(
+      'mask' => array(1 => '%c'),
+      'expected' => '#^testing6/\w+$#',
+    );
+
+    $items['testing7'] = array(
+      'mask' => array(1 => '[0-9a-f]{32}'),
+      'expected' => '#^testing7(?:/[0-9a-f]{32})?$#',
+    );
+
+    $items['testing8'] = array(
+      'mask' => array(1 => '%testing_test'),
+      'expected' => '#^testing8(?:/(test|testing))?$#',
+    );
+
+    $items['testing9'] = array(
+      'mask' => array('testing9', '%d', '%testing_test'),
+      'expected' => '#^testing9(?:/\d+(?:/(test|testing))?)?$#',
+    );
+
+    $items['testing10/%'] = array(
+      'mask' => array(1 => '%d', '%testing_test'),
+      'expected' => '#^testing10/\d+(?:/(test|testing))?$#',
+    );
+
+    $items['testing11/%/%'] = array(
+      'mask' => array(1 => '%d', '%testing_test'),
+      'expected' => '#^testing11/\d+/(test|testing)$#',
+    );
+
+    $items['testing12/%/%'] = array(
+      'mask' => array(2 => '%testing_test'),
+      'expected' => '#^testing12/\d+/(test|testing)$#',
+    );
+
+    $items['testing13'] = array(
+      'mask' => array('rest' => '%d'),
+      'expected' => '#^testing13(?:/\d+)*$#',
+    );
+
+    $items['testing14'] = array(
+      'mask' => array(1 => '%d', 'rest' => '%d'),
+      'expected' => '#^testing14(?:/\d+(?:/\d+)*)? #',
+    );
+
+    foreach ($items as $path => $item) {
+      $item['_parts'] = explode('/', $path);
+      $item['_number_parts'] = count($item['_parts']);
+      $result = _menu_build_mask($item, $path);
+      $this->assertEqual($result, $item['expected'], t('Expecting menu mask to be @expected, got @result.', array('@expected' => $item['expected'], '@result' => $result)));
+    }
+  }
+}
\ No newline at end of file

=== modified file 'modules/system/system.install'
--- modules/system/system.install	2009-05-16 20:25:19 +0000
+++ modules/system/system.install	2009-05-23 00:45:36 +0000
@@ -794,6 +794,13 @@ function system_schema() {
         'not null' => TRUE,
         'default' => 0,
       ),
+      'mask' => array(
+        'description' => 'A regular expression that should match the path.',
+        'type' => 'varchar',
+        'length' => 255,
+        'not null' => TRUE,
+        'default' => '',
+      ),
       'number_parts' => array(
         'description' => 'Number of parts in this router path.',
         'type' => 'int',
@@ -3498,6 +3505,15 @@ function system_update_7023() {
 }
 
 /**
+ * Add the 'mask' column to the {menu_router} table.
+ */
+function system_update_7024() {
+  $ret = array();
+  db_add_field($ret, 'menu_router', 'mask', array('type' => 'varchar', 'length' => 255, 'not null' => TRUE, 'default' => ''));
+  return $ret;
+}
+
+/**
  * @} End of "defgroup updates-6.x-to-7.x"
  * The next series of updates should start at 8000.
  */

=== modified file 'modules/system/system.module'
--- modules/system/system.module	2009-05-16 18:34:22 +0000
+++ modules/system/system.module	2009-05-23 03:23:46 +0000
@@ -979,7 +979,7 @@ function system_admin_menu_block($item) 
     $item += db_query("SELECT mlid, menu_name FROM {menu_links} ml WHERE ml.router_path = :path AND module = 'system'", array(':path' => $item['path']))->fetchAssoc();
   }
   $result = db_query("
-    SELECT m.load_functions, m.to_arg_functions, m.access_callback, m.access_arguments, m.page_callback, m.page_arguments, m.title, m.title_callback, m.title_arguments, m.type, m.description, ml.*
+    SELECT m.load_functions, m.to_arg_functions, m.access_callback, m.access_arguments, m.page_callback, m.page_arguments, m.title, m.title_callback, m.title_arguments, m.type, m.description, m.mask, ml.*
     FROM {menu_links} ml
     LEFT JOIN {menu_router} m ON ml.router_path = m.path
     WHERE ml.plid = :plid AND ml.menu_name = :name AND hidden = 0", array(':plid' => $item['mlid'], ':name' => $item['menu_name']), array('fetch' => PDO::FETCH_ASSOC));
@@ -1528,7 +1528,7 @@ function system_get_module_admin_tasks($
 
   if (!isset($items)) {
     $result = db_query("
-       SELECT m.load_functions, m.to_arg_functions, m.access_callback, m.access_arguments, m.page_callback, m.page_arguments, m.title, m.title_callback, m.title_arguments, m.type, ml.*
+       SELECT m.load_functions, m.to_arg_functions, m.access_callback, m.access_arguments, m.page_callback, m.page_arguments, m.title, m.title_callback, m.title_arguments, m.type, m.mask, ml.*
        FROM {menu_links} ml INNER JOIN {menu_router} m ON ml.router_path = m.path WHERE ml.link_path LIKE 'admin/%' AND hidden >= 0 AND module = 'system' AND m.number_parts > 2", array(), array('fetch' => PDO::FETCH_ASSOC));
     $items = array();
     foreach ($result as $item) {

=== modified file 'modules/taxonomy/taxonomy.module'
--- modules/taxonomy/taxonomy.module	2009-05-19 19:01:51 +0000
+++ modules/taxonomy/taxonomy.module	2009-05-23 00:09:14 +0000
@@ -142,6 +142,7 @@ function taxonomy_menu() {
     'page callback' => 'taxonomy_term_page',
     'page arguments' => array(2),
     'access arguments' => array('access content'),
+    'mask' =>  array(2 => '(?:(?:(?:(?:\d+[+ ])+)|(?:(?:\d+,)*))\d+)', '%d', '(?:feed|page)'),
     'type' => MENU_CALLBACK,
   );
 

=== modified file 'modules/taxonomy/taxonomy.pages.inc'
--- modules/taxonomy/taxonomy.pages.inc	2009-04-26 19:44:37 +0000
+++ modules/taxonomy/taxonomy.pages.inc	2009-05-23 00:09:14 +0000
@@ -95,9 +95,6 @@ function taxonomy_term_page($terms, $dep
 
           node_feed($nids, $channel);
           break;
-
-        default:
-          drupal_not_found();
       }
     }
     else {

