From 13984a7394ec27a2e99b6c7437469f663a331cc3 Mon Sep 17 00:00:00 2001
From: Dries Buytaert <dries@buytaert.net>
Date: Mon, 14 Dec 2009 20:18:56 +0000
Subject: [PATCH] - Patch #639466 by yched: fixed hook_options_list() and XSS
 filtering, and added more tests.

---
 modules/field/modules/list/list.info          |   2 +-
 modules/field/modules/list/list.module        | 165 ++++++++------
 .../field/modules/list/{ => tests}/list.test  | 122 ++++++----
 .../field/modules/list/tests/list_test.info   |   8 +
 .../field/modules/list/tests/list_test.module |  24 ++
 modules/field/modules/options/options.api.php |  65 ++++++
 modules/field/modules/options/options.module  | 208 +++++++++++++-----
 modules/field/modules/options/options.test    |  80 ++++++-
 modules/taxonomy/taxonomy.module              |  27 +--
 9 files changed, 508 insertions(+), 193 deletions(-)
 rename modules/field/modules/list/{ => tests}/list.test (53%)
 create mode 100644 modules/field/modules/list/tests/list_test.info
 create mode 100644 modules/field/modules/list/tests/list_test.module
 create mode 100644 modules/field/modules/options/options.api.php

diff --git a/modules/field/modules/list/list.info b/modules/field/modules/list/list.info
index af520247459c..bf4bb8182007 100644
--- a/modules/field/modules/list/list.info
+++ b/modules/field/modules/list/list.info
@@ -5,5 +5,5 @@ package = Core - fields
 version = VERSION
 core = 7.x
 files[]=list.module
-files[]=list.test
+files[]=tests/list.test
 required = TRUE
diff --git a/modules/field/modules/list/list.module b/modules/field/modules/list/list.module
index 9838f0a7247a..29e4ea4d2d27 100644
--- a/modules/field/modules/list/list.module
+++ b/modules/field/modules/list/list.module
@@ -115,7 +115,7 @@ function list_field_settings_form($field, $instance, $has_data) {
     '#required' => FALSE,
     '#rows' => 10,
     '#description' => '<p>' . t('The possible values this field can contain. Enter one value per line, in the format key|label. The key is the value that will be stored in the database, and must be a %type value. The label is optional, and the key will be used as the label if no label is specified.', array('%type' => $field['type'] == 'list_text' ? t('text') : t('numeric'))) . '</p>',
-    '#element_validate' => array('list_allowed_values_validate'),
+    '#element_validate' => array('list_allowed_values_setting_validate'),
     '#list_field_type' => $field['type'],
     '#access' => empty($settings['allowed_values_function']),
   );
@@ -144,13 +144,35 @@ function list_field_settings_form($field, $instance, $has_data) {
 }
 
 /**
- * Implements hook_field_create_field().
+ * Element validate callback; check that the entered values are valid.
  */
-function list_field_create_field($field) {
-  if (array_key_exists($field['type'], list_field_info())) {
-    // Clear the static cache of allowed values for $field.
-    $allowed_values = &drupal_static('list_allowed_values', array());
-    unset($allowed_values[$field['field_name']]);
+function list_allowed_values_setting_validate($element, &$form_state) {
+  $values = list_extract_allowed_values($element['#value'], $element['#list_field_type'] == 'list');
+  $field_type = $element['#list_field_type'];
+
+  // Check that keys are valid for the field type.
+  foreach ($values as $key => $value) {
+    if ($field_type == 'list_number' && !is_numeric($key)) {
+      form_error($element, t('Allowed values list: each key must be a valid integer or decimal.'));
+      break;
+    }
+    elseif ($field_type == 'list_text' && strlen($key) > 255) {
+      form_error($element, t('Allowed values list: each key must be a string less than 255 characters.'));
+      break;
+    }
+    elseif ($field_type == 'list' && !preg_match('/^-?\d+$/', $key)) {
+      form_error($element, t('Allowed values list: keys must be integers.'));
+      break;
+    }
+    elseif ($field_type == 'list_boolean' && !in_array($key, array('0', '1'))) {
+      form_error($element, t('Allowed values list: keys must be either 0 or 1.'));
+      break;
+    }
+  }
+
+  // Check that boolean fields get two values.
+  if ($field_type == 'list_boolean' && count($values) != 2) {
+    form_error($element, t('Allowed values list: two values are required.'));
   }
 }
 
@@ -158,64 +180,67 @@ function list_field_create_field($field) {
  * Implements hook_field_update_field().
  */
 function list_field_update_field($field, $prior_field, $has_data) {
-  if (array_key_exists($field['type'], list_field_info())) {
-    // Clear the static cache of allowed values for $field.
-    $allowed_values = &drupal_static('list_allowed_values', array());
-    unset($allowed_values[$field['field_name']]);
-  }
+  drupal_static_reset('list_allowed_values');
 }
 
 /**
- * Create an array of allowed values for this field.
+ * Returns the set of allowed values for a list field.
+ *
+ * The strings are not safe for output. Keys and values of the array should be
+ * sanitized through field_filter_xss() before being displayed.
+ *
+ * @param $field
+ *   The field definition.
+ *
+ * @return
+ *   The array of allowed values. Keys of the array are the raw stored values
+ *   (integer or text), values of the array are the display aliases.
  */
 function list_allowed_values($field) {
-  // This static cache must be cleared whenever $field['field_name']
-  // changes. This includes when it is created because a different
-  // field with the same name may have previously existed, as well
-  // as when it is updated.
   $allowed_values = &drupal_static(__FUNCTION__, array());
 
-  if (isset($allowed_values[$field['field_name']])) {
-    return $allowed_values[$field['field_name']];
-  }
+  if (!isset($allowed_values[$field['id']])) {
+    $values = array();
 
-  $allowed_values[$field['field_name']] = array();
+    $function = $field['settings']['allowed_values_function'];
+    if (!empty($function) && function_exists($function)) {
+      $values = $function($field);
+    }
+    elseif (!empty($field['settings']['allowed_values'])) {
+      $position_keys = $field['type'] == 'list';
+      $values = list_extract_allowed_values($field['settings']['allowed_values'], $position_keys);
+    }
 
-  $function = $field['settings']['allowed_values_function'];
-  if (!empty($function) && function_exists($function)) {
-    $allowed_values[$field['field_name']] = $function($field);
-  }
-  elseif (!empty($field['settings']['allowed_values'])) {
-    $allowed_values[$field['field_name']] = list_allowed_values_list($field['settings']['allowed_values'], $field['type'] == 'list');
+    $allowed_values[$field['id']] = $values;
   }
 
-  return $allowed_values[$field['field_name']];
+  return $allowed_values[$field['id']];
 }
 
 /**
- * Create an array of the allowed values for this field.
+ * Generates an array of values from a string.
  *
  * Explode a string with keys and labels separated with '|' and with each new
  * value on its own line.
  *
  * @param $string_values
- *   The list of choices as a string.
+ *   The list of choices as a string, in the format expected by the
+ *   'allowed_values' setting:
+ *    - Values are separated by a carriage return.
+ *    - Each value is in the format "value|label" or "value".
  * @param $position_keys
  *   Boolean value indicating whether to generate keys based on the position of
  *   the value if a key is not manually specified, effectively generating
  *   integer-based keys. This should only be TRUE for fields that have a type of
  *   "list". Otherwise the value will be used as the key if not specified.
  */
-function list_allowed_values_list($string_values, $position_keys = FALSE) {
-  $allowed_values = array();
+function list_extract_allowed_values($string_values, $position_keys = FALSE) {
+  $values = array();
 
   $list = explode("\n", $string_values);
   $list = array_map('trim', $list);
   $list = array_filter($list, 'strlen');
   foreach ($list as $key => $value) {
-    // Sanitize the user input with a permissive filter.
-    $value = field_filter_xss($value);
-
     // Check for a manually specified key.
     if (strpos($value, '|') !== FALSE) {
       list($key, $value) = explode('|', $value);
@@ -225,43 +250,10 @@ function list_allowed_values_list($string_values, $position_keys = FALSE) {
     elseif (!$position_keys) {
       $key = $value;
     }
-    $allowed_values[$key] = (isset($value) && $value !== '') ? $value : $key;
+    $values[$key] = (isset($value) && $value !== '') ? $value : $key;
   }
 
-  return $allowed_values;
-}
-
-/**
- * Element validate callback; check that the entered values are valid.
- */
-function list_allowed_values_validate($element, &$form_state) {
-  $values = list_allowed_values_list($element['#value'], $element['#list_field_type'] == 'list');
-  $field_type = $element['#list_field_type'];
-
-  // Check that keys are valid for the field type.
-  foreach ($values as $key => $value) {
-    if ($field_type == 'list_number' && !is_numeric($key)) {
-      form_error($element, t('Allowed values list: each key must be a valid integer or decimal.'));
-      break;
-    }
-    elseif ($field_type == 'list_text' && strlen($key) > 255) {
-      form_error($element, t('Allowed values list: each key must be a string less than 255 characters.'));
-      break;
-    }
-    elseif ($field_type == 'list' && !preg_match('/^-?\d+$/', $key)) {
-      form_error($element, t('Allowed values list: keys must be integers.'));
-      break;
-    }
-    elseif ($field_type == 'list_boolean' && !in_array($key, array('0', '1'))) {
-      form_error($element, t('Allowed values list: keys must be either 0 or 1.'));
-      break;
-    }
-  }
-
-  // Check that boolean fields get two values.
-  if ($field_type == 'list_boolean' && count($values) != 2) {
-    form_error($element, t('Allowed values list: two values are required.'));
-  }
+  return $values;
 }
 
 /**
@@ -294,6 +286,33 @@ function list_field_is_empty($item, $field) {
   return FALSE;
 }
 
+/**
+ * Implements hook_field_widget_info_alter().
+ *
+ * The List module does not implement widgets of its own, but reuses the
+ * widgets defined in options.module.
+ *
+ * @see list_options_list().
+ */
+function list_field_widget_info_alter(&$info) {
+  $widgets = array(
+    'options_select' => array('list', 'list_text', 'list_number', 'list_boolean'),
+    'options_buttons' => array('list', 'list_text', 'list_number', 'list_boolean'),
+    'options_onoff' => array('list_boolean'),
+  );
+
+  foreach ($widgets as $widget => $field_types) {
+    $info[$widget]['field types'] = array_merge($info[$widget]['field types'], $field_types);
+  }
+}
+
+/**
+ * Implements hook_options_list().
+ */
+function list_options_list($field) {
+  return list_allowed_values($field);
+}
+
 /**
  * Implements hook_field_formatter_info().
  */
@@ -321,11 +340,11 @@ function list_field_formatter($object_type, $object, $field, $instance, $langcod
       $allowed_values = list_allowed_values($field);
       foreach ($items as $delta => $item) {
         if (isset($allowed_values[$item['value']])) {
-          $output = $allowed_values[$item['value']];
+          $output = field_filter_xss($allowed_values[$item['value']]);
         }
         else {
           // If no match was found in allowed values, fall back to the key.
-          $output = $value;
+          $output = field_filter_xss($value);
         }
         $element[$delta] = array('#markup' => $output);
       }
@@ -333,7 +352,7 @@ function list_field_formatter($object_type, $object, $field, $instance, $langcod
 
     case 'list_key':
       foreach ($items as $delta => $item) {
-        $element[$delta] = array('#markup' => $item['value']);
+        $element[$delta] = array('#markup' => field_filter_xss($item['value']));
       }
       break;
   }
diff --git a/modules/field/modules/list/list.test b/modules/field/modules/list/tests/list.test
similarity index 53%
rename from modules/field/modules/list/list.test
rename to modules/field/modules/list/tests/list.test
index d53a0fd05522..805d3485fd1e 100644
--- a/modules/field/modules/list/list.test
+++ b/modules/field/modules/list/tests/list.test
@@ -1,105 +1,133 @@
 <?php
 // $Id$
 
-class ListFieldTestCase extends DrupalWebTestCase {
+/**
+ * @file
+ * Tests for the 'List' field types.
+ */
+
+/**
+ * Tests for the 'List' field types.
+ */
+class ListFieldTestCase extends FieldTestCase {
   public static function getInfo() {
     return array(
-      'name'  => 'List field',
-      'description'  => "Test the List field type.",
-      'group' => 'Field types'
+      'name' => 'List field',
+      'description' => 'Test the List field type.',
+      'group' => 'Field types',
     );
   }
 
   function setUp() {
     parent::setUp('field_test');
 
-    $this->card_1 = array(
-      'field_name' => 'card_1',
+    $this->field_name = 'field_test';
+    $this->field = array(
+      'field_name' => $this->field_name,
       'type' => 'list',
       'cardinality' => 1,
       'settings' => array(
         'allowed_values' => "1|One\n2|Two\n3|Three\n",
       ),
     );
-    $this->card_1 = field_create_field($this->card_1);
+    $this->field = field_create_field($this->field);
 
-    $this->instance_1 = array(
-      'field_name' => $this->card_1['field_name'],
+    $this->instance = array(
+      'field_name' => $this->field_name,
       'object_type' => 'test_entity',
       'bundle' => 'test_bundle',
       'widget' => array(
         'type' => 'options_buttons',
       ),
     );
-    $this->instance_1 = field_create_instance($this->instance_1);
+    $this->instance = field_create_instance($this->instance);
   }
 
   /**
-   * Test that allowed values can be updated and that the updates are
-   * reflected in generated forms.
+   * Test that allowed values can be updated.
    */
   function testUpdateAllowedValues() {
+    $langcode = LANGUAGE_NONE;
+
     // All three options appear.
     $entity = field_test_create_stub_entity();
     $form = drupal_get_form('field_test_entity_form', $entity);
-    $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][1]), t('Option 1 exists'));
-    $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][2]), t('Option 2 exists'));
-    $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][3]), t('Option 3 exists'));
+    $this->assertTrue(!empty($form[$this->field_name][$langcode][1]), t('Option 1 exists'));
+    $this->assertTrue(!empty($form[$this->field_name][$langcode][2]), t('Option 2 exists'));
+    $this->assertTrue(!empty($form[$this->field_name][$langcode][3]), t('Option 3 exists'));
 
     // Removed options do not appear.
-    $this->card_1['settings']['allowed_values'] = "2|Two";
-    field_update_field($this->card_1);
+    $this->field['settings']['allowed_values'] = "2|Two";
+    field_update_field($this->field);
     $entity = field_test_create_stub_entity();
     $form = drupal_get_form('field_test_entity_form', $entity);
-    $this->assertTrue(empty($form['card_1'][LANGUAGE_NONE][1]), t('Option 1 does not exist'));
-    $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][2]), t('Option 2 exists'));
-    $this->assertTrue(empty($form['card_1'][LANGUAGE_NONE][3]), t('Option 3 does not exist'));
+    $this->assertTrue(empty($form[$this->field_name][$langcode][1]), t('Option 1 does not exist'));
+    $this->assertTrue(!empty($form[$this->field_name][$langcode][2]), t('Option 2 exists'));
+    $this->assertTrue(empty($form[$this->field_name][$langcode][3]), t('Option 3 does not exist'));
 
     // Completely new options appear.
-    $this->card_1['settings']['allowed_values'] = "10|Update\n20|Twenty";
-    field_update_field($this->card_1);
+    $this->field['settings']['allowed_values'] = "10|Update\n20|Twenty";
+    field_update_field($this->field);
     $form = drupal_get_form('field_test_entity_form', $entity);
-    $this->assertTrue(empty($form['card_1'][LANGUAGE_NONE][1]), t('Option 1 does not exist'));
-    $this->assertTrue(empty($form['card_1'][LANGUAGE_NONE][2]), t('Option 2 does not exist'));
-    $this->assertTrue(empty($form['card_1'][LANGUAGE_NONE][3]), t('Option 3 does not exist'));
-    $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][10]), t('Option 10 exists'));
-    $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][20]), t('Option 20 exists'));
+    $this->assertTrue(empty($form[$this->field_name][$langcode][1]), t('Option 1 does not exist'));
+    $this->assertTrue(empty($form[$this->field_name][$langcode][2]), t('Option 2 does not exist'));
+    $this->assertTrue(empty($form[$this->field_name][$langcode][3]), t('Option 3 does not exist'));
+    $this->assertTrue(!empty($form[$this->field_name][$langcode][10]), t('Option 10 exists'));
+    $this->assertTrue(!empty($form[$this->field_name][$langcode][20]), t('Option 20 exists'));
 
     // Options are reset when a new field with the same name is created.
-    field_delete_field($this->card_1['field_name']);
-    unset($this->card_1['id']);
-    $this->card_1['settings']['allowed_values'] = "1|One\n2|Two\n3|Three\n";
-    $this->card_1 = field_create_field($this->card_1);
-    $this->instance_1 = array(
-      'field_name' => $this->card_1['field_name'],
+    field_delete_field($this->field_name);
+    unset($this->field['id']);
+    $this->field['settings']['allowed_values'] = "1|One\n2|Two\n3|Three\n";
+    $this->field = field_create_field($this->field);
+    $this->instance = array(
+      'field_name' => $this->field_name,
       'object_type' => 'test_entity',
       'bundle' => 'test_bundle',
       'widget' => array(
         'type' => 'options_buttons',
       ),
     );
-    $this->instance_1 = field_create_instance($this->instance_1);
+    $this->instance = field_create_instance($this->instance);
     $entity = field_test_create_stub_entity();
     $form = drupal_get_form('field_test_entity_form', $entity);
-    $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][1]), t('Option 1 exists'));
-    $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][2]), t('Option 2 exists'));
-    $this->assertTrue(!empty($form['card_1'][LANGUAGE_NONE][3]), t('Option 3 exists'));
+    $this->assertTrue(!empty($form[$this->field_name][$langcode][1]), t('Option 1 exists'));
+    $this->assertTrue(!empty($form[$this->field_name][$langcode][2]), t('Option 2 exists'));
+    $this->assertTrue(!empty($form[$this->field_name][$langcode][3]), t('Option 3 exists'));
   }
-  
 }
 
 /**
 * List module UI tests.
 */
-class ListFieldUITestCase extends FieldUITestCase {
+class ListFieldUITestCase extends FieldTestCase {
   public static function getInfo() {
     return array(
-      'name' => 'List field UI tests',
+      'name' => 'List field UI',
       'description' => 'Test the List field UI functionality.',
       'group' => 'Field types',
     );
   }
 
+  function setUp() {
+    parent::setUp('field_test', 'field_ui');
+
+    // Create test user.
+    $admin_user = $this->drupalCreateUser(array('access content', 'administer content types', 'administer taxonomy'));
+    $this->drupalLogin($admin_user);
+
+    // Create content type, with underscores.
+    $type_name = strtolower($this->randomName(8)) . '_' .'test';
+    $type = $this->drupalCreateContentType(array('name' => $type_name, 'type' => $type_name));
+    $this->type = $type->type;
+    // Store a valid URL name, with hyphens instead of underscores.
+    $this->hyphen_type = str_replace('_', '-', $this->type);
+
+    // Create random field name.
+    $this->field_label = $this->randomName(8);
+    $this->field_name = 'field_' . strtolower($this->randomName(8));
+  }
+
   /**
    * Tests that allowed values are properly validated in the UI.
    */
@@ -126,23 +154,23 @@ class ListFieldUITestCase extends FieldUITestCase {
     $edit = array($element_name => "1|one\n" . $this->randomName(255) . "|two");
     $this->drupalPost($admin_path, $edit, t('Save settings'));
     $this->assertText("each key must be a string less than 255 characters", t('Form vaildation failed.'));
-    
+
     // Test 'List (boolean)' field type.
-    $admin_path = $this->createListFieldAndEdit('list_boolean');    
+    $admin_path = $this->createListFieldAndEdit('list_boolean');
     // Check that invalid option keys are rejected.
     $edit = array($element_name => "1|one\n2|two");
     $this->drupalPost($admin_path, $edit, t('Save settings'));
     $this->assertText("keys must be either 0 or 1", t('Form vaildation failed.'));
-    
+
     //Check that missing option causes failure.
     $edit = array($element_name => "1|one");
     $this->drupalPost($admin_path, $edit, t('Save settings'));
-    $this->assertText("two values are required", t('Form vaildation failed.'));    
+    $this->assertText("two values are required", t('Form vaildation failed.'));
   }
-  
+
   /**
    * Helper function to create list field of a given type and get the edit page.
-   * 
+   *
    * @param string $type
    *   'list', 'list_boolean', 'list_number', or 'list_text'
    */
@@ -164,6 +192,6 @@ class ListFieldUITestCase extends FieldUITestCase {
     $admin_path = 'admin/structure/types/manage/' . $this->hyphen_type . '/fields/' . $field_name;
     return $admin_path;
   }
-  
+
 }
 
diff --git a/modules/field/modules/list/tests/list_test.info b/modules/field/modules/list/tests/list_test.info
new file mode 100644
index 000000000000..32fec873d1d6
--- /dev/null
+++ b/modules/field/modules/list/tests/list_test.info
@@ -0,0 +1,8 @@
+;$Id$
+name = "List test"
+description = "Support module for the List module tests."
+core = 7.x
+package = Testing
+files[] = list_test.module
+version = VERSION
+hidden = TRUE
diff --git a/modules/field/modules/list/tests/list_test.module b/modules/field/modules/list/tests/list_test.module
new file mode 100644
index 000000000000..4fb1998eb113
--- /dev/null
+++ b/modules/field/modules/list/tests/list_test.module
@@ -0,0 +1,24 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Helper module for the List module tests.
+ */
+
+/**
+ * Allowed values callback.
+ */
+function list_test_allowed_values_callback($field) {
+  $values = array(
+    'Group 1' => array(
+      0 => 'Zero',
+    ),
+    1 => 'One',
+    'Group 2' => array(
+      2 => 'Some <script>dangerous</script> & unescaped <strong>markup</strong>',
+    ),
+  );
+
+  return $values;
+}
diff --git a/modules/field/modules/options/options.api.php b/modules/field/modules/options/options.api.php
new file mode 100644
index 000000000000..72ee8674b0e2
--- /dev/null
+++ b/modules/field/modules/options/options.api.php
@@ -0,0 +1,65 @@
+<?php
+// $Id$
+
+/**
+ * @file
+ * Hooks provided by the Options module.
+ */
+
+/**
+ * Returns the list of options to be displayed for a field.
+ *
+ * Field types willing to enable one or several of the widgets defined in
+ * options.module (select, radios/checkboxes, on/off checkbox) need to
+ * implement this hook to specify the list of options to display in the
+ * widgets.
+ *
+ * @param $field
+ *   The field definition.
+ *
+ * @return
+ *   The array of options for the field. Array keys are the values to be
+ *   stored, and should be of the data type (string, number...) expected by
+ *   the first 'column' for the field type. Array values are the labels to
+ *   display within the widgets. The labels should NOT be sanitized,
+ *   options.module takes care of sanitation according to the needs of each
+ *   widget. The HTML tags defined in _field_filter_xss_allowed_tags() are
+ *   allowed, other tags will be filtered.
+ */
+function hook_options_list($field) {
+  // Sample structure.
+  $options = array(
+    0 => t('Zero'),
+    1 => t('One'),
+    2 => t('Two'),
+    3 => t('Three'),
+  );
+
+  // Sample structure with groups. Only one level of nesting is allowed. This
+  // is only supported by the 'options_select' widget. Other widgets will
+  // flatten the array.
+  $options = array(
+    t('First group') => array(
+      0 => t('Zero'),
+    ),
+    t('Second group') => array(
+      1 => t('One'),
+      2 => t('Two'),
+    ),
+    3 => t('Three'),
+  );
+
+  // In actual implementations, the array of options will most probably depend
+  // on properties of the field. Example from taxonomy.module:
+  $options = array();
+  foreach ($field['settings']['allowed_values'] as $tree) {
+    $terms = taxonomy_get_tree($tree['vid'], $tree['parent']);
+    if ($terms) {
+      foreach ($terms as $term) {
+        $options[$term->tid] = str_repeat('-', $term->depth) . $term->name;
+      }
+    }
+  }
+
+  return $options;
+}
diff --git a/modules/field/modules/options/options.module b/modules/field/modules/options/options.module
index 42f881088a15..78a32e2ca547 100644
--- a/modules/field/modules/options/options.module
+++ b/modules/field/modules/options/options.module
@@ -32,26 +32,32 @@ function options_theme() {
 
 /**
  * Implements hook_field_widget_info().
+ *
+ * Field type modules willing to use those widgets should:
+ * - Use hook_field_widget_info_alter() to append their field own types to the
+ *   list of types supported by the widgets,
+ * - Implement hook_options_list() to provide the list of options.
+ * See list.module.
  */
 function options_field_widget_info() {
   return array(
     'options_select' => array(
       'label' => t('Select list'),
-      'field types' => array('list', 'list_boolean', 'list_text', 'list_number'),
+      'field types' => array(),
       'behaviors' => array(
         'multiple values' => FIELD_BEHAVIOR_CUSTOM,
       ),
     ),
     'options_buttons' => array(
       'label' => t('Check boxes/radio buttons'),
-      'field types' => array('list', 'list_boolean', 'list_text', 'list_number'),
+      'field types' => array(),
       'behaviors' => array(
         'multiple values' => FIELD_BEHAVIOR_CUSTOM,
       ),
     ),
     'options_onoff' => array(
       'label' => t('Single on/off checkbox'),
-      'field types' => array('list_boolean'),
+      'field types' => array(),
       'behaviors' => array(
         'multiple values' => FIELD_BEHAVIOR_CUSTOM,
       ),
@@ -66,61 +72,64 @@ function options_field_widget(&$form, &$form_state, $field, $instance, $langcode
   // Abstract over the actual field columns, to allow different field types to
   // reuse those widgets.
   $value_key = key($field['columns']);
+
+  $type = str_replace('options_', '', $instance['widget']['type']);
   $multiple = $field['cardinality'] > 1 || $field['cardinality'] == FIELD_CARDINALITY_UNLIMITED;
-  // Form API 'checkboxes' do not suport 0 as an option, so we replace it with
-  // a placeholder within the form workflow.
-  $zero_placeholder = $instance['widget']['type'] == 'options_buttons' && $multiple;
-  // Collect available options for the field.
-  $options = options_get_options($field, $instance, $zero_placeholder);
+  $required = $element['#required'];
+  $properties = _options_properties($type, $multiple, $required);
+
+  // Prepare the list of options.
+  $options = _options_get_options($field, $instance, $properties);
+
   // Put current field values in shape.
-  $default_value = _options_storage_to_form($items, $options, $value_key, $zero_placeholder);
+  $default_value = _options_storage_to_form($items, $options, $value_key, $properties);
 
-  switch ($instance['widget']['type']) {
-    case 'options_select':
+  switch ($type) {
+    case 'select':
       $element += array(
         '#type' => 'select',
         '#default_value' => $default_value,
         // Do not display a 'multiple' select box if there is only one option.
         '#multiple' => $multiple && count($options) > 1,
         '#options' => $options,
-        '#value_key' => $value_key,
-        '#element_validate' => array('options_field_widget_validate'),
       );
       break;
 
-    case 'options_buttons':
-      $type = $multiple ? 'checkboxes' : 'radios';
+    case 'buttons':
       // If required and there is one single option, preselect it.
-      if ($element['#required'] && count($options) == 1) {
+      if ($required && count($options) == 1) {
+        reset($options);
         $default_value = array(key($options));
       }
       $element += array(
-        '#type' => $type,
+        '#type' => $multiple ? 'checkboxes' : 'radios',
         // Radio buttons need a scalar value.
-        '#default_value' => ($type == 'radios') ? reset($default_value) : $default_value,
+        '#default_value' => $multiple ? $default_value : reset($default_value),
         '#options' => $options,
-        '#zero_placeholder' => $zero_placeholder,
-        '#value_key' => $value_key,
-        '#element_validate' => array('options_field_widget_validate'),
       );
       break;
 
-    case 'options_onoff':
+    case 'onoff':
       $keys = array_keys($options);
-      $off_value = (!empty($keys) && isset($keys[0])) ? $keys[0] : NULL;
-      $on_value = (!empty($keys) && isset($keys[1])) ? $keys[1] : NULL;
+      $off_value = array_shift($keys);
+      $on_value = array_shift($keys);
       $element += array(
         '#type' => 'checkbox',
-        '#title' => isset($options[$on_value]) ? $options[$on_value] : '',
         '#default_value' => (isset($default_value[0]) && $default_value[0] == $on_value) ? 1 : 0,
         '#on_value' => $on_value,
         '#off_value' => $off_value,
-        '#value_key' => $value_key,
-        '#element_validate' => array('options_field_widget_validate'),
       );
+      // Override the title from the incoming $element.
+      $element['#title'] = isset($options[$on_value]) ? $options[$on_value] : '';
       break;
   }
 
+  $element += array(
+    '#value_key' => $value_key,
+    '#element_validate' => array('options_field_widget_validate'),
+    '#properties' => $properties,
+  );
+
   return $element;
 }
 
@@ -135,54 +144,123 @@ function options_field_widget_validate($element, &$form_state) {
 }
 
 /**
- * Prepares the options for a field.
+ * Describes the preparation steps required by each widget.
  */
-function options_get_options($field, $instance, $zero_placeholder) {
-  // Check if there is a module hook for the option values, otherwise try
-  // list_allowed_values() for an options list.
-  // @todo This should be turned into a hook_options_allowed_values(), exposed
-  // by options.module.
-  $function = $field['module'] . '_allowed_values';
-  $options = function_exists($function) ? $function($field) : (array) list_allowed_values($field);
+function _options_properties($type, $multiple, $required) {
+  $base = array(
+    'zero_placeholder' => FALSE,
+    'filter_xss' => FALSE,
+    'strip_tags' => FALSE,
+    'empty_value' => FALSE,
+    'optgroups' => FALSE,
+  );
+
+  switch ($type) {
+    case 'select':
+      $properties = array(
+        // Select boxes do not support any HTML tag.
+        'strip_tags' => TRUE,
+        'empty_value' => !$required,
+        'optgroups' => TRUE,
+      );
+      break;
+
+    case 'buttons':
+      $properties = array(
+        'filter_xss' => TRUE,
+        // Form API 'checkboxes' do not suport 0 as an option, so we replace it with
+        // a placeholder within the form workflow.
+        'zero_placeholder' => $multiple,
+        // Checkboxes do not need a 'none' choice.
+        'empty_value' => !$required && !$multiple,
+      );
+      break;
+
+    case 'onoff':
+      $properties = array(
+        'filter_xss' => TRUE,
+      );
+      break;
+  }
+
+  return $properties + $base;
+}
+
+/**
+ * Collects the options for a field.
+ */
+function _options_get_options($field, $instance, $properties) {
+  // Get the list of options.
+  $options = (array) module_invoke($field['module'], 'options_list', $field);
+
+  // Sanitize the options.
+  _options_prepare_options($options, $properties);
+
+  if (!$properties['optgroups']) {
+    $options = options_array_flatten($options);
+  }
+
+  if ($properties['empty_value']) {
+    $options = array('_none' => theme('options_none', array('instance' => $instance))) + $options;
+  }
+
+  return $options;
+}
 
+/**
+ * Sanitizes the options.
+ *
+ * The function is recursive to support optgroups.
+ */
+function _options_prepare_options(&$options, $properties) {
   // Substitute the '_0' placeholder.
-  if ($zero_placeholder) {
+  if ($properties['zero_placeholder']) {
     $values = array_keys($options);
+    $labels = array_values($options);
     // Use a strict comparison, because 0 == 'any string'.
     $index = array_search(0, $values, TRUE);
-    if ($index !== FALSE) {
+    if ($index !== FALSE && !is_array($options[$index])) {
       $values[$index] = '_0';
-      $options = array_combine($values, array_values($options));
+      $options = array_combine($values, $labels);
     }
   }
 
-  // Add an empty choice for
-  // - non required radios
-  // - non required selects
-  if (!$instance['required']) {
-    if (($instance['widget']['type'] == 'options_buttons' && ($field['cardinality'] == 1)) || ($instance['widget']['type'] == 'options_select')) {
-      $options = array('_none' => theme('options_none', array('instance' => $instance))) + $options;
+  foreach ($options as $value => $label) {
+    // Recurse for optgroups.
+    if (is_array($label)) {
+      _options_prepare_options($options[$value], $properties);
+    }
+    else {
+      if ($properties['strip_tags']) {
+        $options[$value] = strip_tags($label);
+      }
+      if ($properties['filter_xss']) {
+        $options[$value] = field_filter_xss($label);
+      }
     }
   }
-  return $options;
 }
 
 /**
  * Transforms stored field values into the format the widgets need.
  */
-function _options_storage_to_form($items, $options, $column, $zero_placeholder) {
+function _options_storage_to_form($items, $options, $column, $properties) {
   $items_transposed = options_array_transpose($items);
   $values = (isset($items_transposed[$column]) && is_array($items_transposed[$column])) ? $items_transposed[$column] : array();
 
   // Substitute the '_0' placeholder.
-  if ($zero_placeholder) {
+  if ($properties['zero_placeholder']) {
     $index = array_search('0', $values);
     if ($index !== FALSE) {
       $values[$index] = '_0';
     }
   }
 
-  // Discard values that are not in the current list of options.
+  // Discard values that are not in the current list of options. Flatten the
+  // array if needed.
+  if ($properties['optgroups']) {
+    $options = options_array_flatten($options);
+  }
   $values = array_values(array_intersect($values, array_keys($options)));
   return $values;
 }
@@ -192,6 +270,7 @@ function _options_storage_to_form($items, $options, $column, $zero_placeholder)
  */
 function _options_form_to_storage($element) {
   $values = array_values((array) $element['#value']);
+  $properties = $element['#properties'];
 
   // On/off checkbox: transform '0 / 1' into the 'on / off' values.
   if ($element['#type'] == 'checkbox') {
@@ -199,7 +278,7 @@ function _options_form_to_storage($element) {
   }
 
   // Substitute the '_0' placeholder.
-  if (!empty($element['#zero_placeholder'])) {
+  if ($properties['zero_placeholder']) {
     $index = array_search('_0', $values);
     if ($index !== FALSE) {
       $values[$index] = 0;
@@ -208,9 +287,11 @@ function _options_form_to_storage($element) {
 
   // Filter out the 'none' option. Use a strict comparison, because
   // 0 == 'any string'.
-  $index = array_search('_none', $values, TRUE);
-  if ($index !== FALSE) {
-    unset($values[$index]);
+  if ($properties['empty_value']) {
+    $index = array_search('_none', $values, TRUE);
+    if ($index !== FALSE) {
+      unset($values[$index]);
+    }
   }
 
   // Make sure we populate at least an empty value.
@@ -252,6 +333,29 @@ function options_array_transpose($array) {
   return $result;
 }
 
+/**
+ * Flattens an array of allowed values.
+ *
+ * @param $array
+ *   A single or multidimensional array.
+ * @return
+ *   A flattened array.
+ */
+function options_array_flatten($array) {
+  $result = array();
+  if (is_array($array)) {
+    foreach ($array as $key => $value) {
+      if (is_array($value)) {
+        $result += options_array_flatten($value);
+      }
+      else {
+        $result[$key] = $value;
+      }
+    }
+  }
+  return $result;
+}
+
 /**
  * Implements hook_field_widget_error().
  */
diff --git a/modules/field/modules/options/options.test b/modules/field/modules/options/options.test
index d8bfeac060c7..764c45c52bc7 100644
--- a/modules/field/modules/options/options.test
+++ b/modules/field/modules/options/options.test
@@ -11,7 +11,7 @@ class OptionsWidgetsTestCase extends FieldTestCase {
   }
 
   function setUp() {
-    parent::setUp('field_test');
+    parent::setUp('field_test', 'list_test');
 
     // Field with cardinality 1.
     $this->card_1 = array(
@@ -20,7 +20,7 @@ class OptionsWidgetsTestCase extends FieldTestCase {
       'cardinality' => 1,
       'settings' => array(
         // Make sure that 0 works as an option.
-        'allowed_values' => "0|Zero\n1|One\n2|Two\n",
+        'allowed_values' => "0|Zero\n1|One\n2|Some <script>dangerous</script> & unescaped <strong>markup</strong>\n",
       ),
     );
     $this->card_1 = field_create_field($this->card_1);
@@ -32,7 +32,7 @@ class OptionsWidgetsTestCase extends FieldTestCase {
       'cardinality' => 2,
       'settings' => array(
         // Make sure that 0 works as an option.
-        'allowed_values' => "0|Zero\n1|One\n2|Two\n",
+        'allowed_values' => "0|Zero\n1|One\n2|Some <script>dangerous</script> & unescaped <strong>markup</strong>\n",
       ),
     );
     $this->card_2 = field_create_field($this->card_2);
@@ -44,7 +44,7 @@ class OptionsWidgetsTestCase extends FieldTestCase {
       'cardinality' => 1,
       'settings' => array(
         // Make sure that 0 works as a 'on' value'.
-        'allowed_values' => "1|No\n0|Yes\n",
+        'allowed_values' => "1|No\n0|Some <script>dangerous</script> & unescaped <strong>markup</strong>\n",
       ),
     );
     $this->bool = field_create_field($this->bool);
@@ -81,6 +81,7 @@ class OptionsWidgetsTestCase extends FieldTestCase {
     $this->assertNoFieldChecked("edit-card-1-$langcode-0");
     $this->assertNoFieldChecked("edit-card-1-$langcode-1");
     $this->assertNoFieldChecked("edit-card-1-$langcode-2");
+    $this->assertRaw('Some dangerous &amp; unescaped <strong>markup</strong>', t('Option text was properly filtered.'));
 
     // Select first option.
     $edit = array("card_1[$langcode]" => 0);
@@ -98,7 +99,7 @@ class OptionsWidgetsTestCase extends FieldTestCase {
     $this->drupalPost(NULL, $edit, t('Save'));
     $this->assertFieldValues($entity_init, 'card_1', $langcode, array());
 
-    // Required radios with one option is auto-selected.
+    // Check that required radios with one option is auto-selected.
     $this->card_1['settings']['allowed_values'] = '99|Only allowed value';
     field_update_field($this->card_1);
     $instance['required'] = TRUE;
@@ -137,6 +138,7 @@ class OptionsWidgetsTestCase extends FieldTestCase {
     $this->assertNoFieldChecked("edit-card-2-$langcode--0");
     $this->assertNoFieldChecked("edit-card-2-$langcode-1");
     $this->assertNoFieldChecked("edit-card-2-$langcode-2");
+    $this->assertRaw('Some dangerous &amp; unescaped <strong>markup</strong>', t('Option text was properly filtered.'));
 
     // Submit form: select first and third options.
     $edit = array(
@@ -223,6 +225,7 @@ class OptionsWidgetsTestCase extends FieldTestCase {
     $this->assertNoOptionSelected("edit-card-1-$langcode", 0);
     $this->assertNoOptionSelected("edit-card-1-$langcode", 1);
     $this->assertNoOptionSelected("edit-card-1-$langcode", 2);
+    $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
 
     // Submit form: select first option.
     $edit = array("card_1[$langcode]" => 0);
@@ -248,6 +251,38 @@ class OptionsWidgetsTestCase extends FieldTestCase {
 
     // We do not have to test that a required select list with one option is
     // auto-selected because the browser does it for us.
+
+    // Test optgroups.
+
+    $this->card_1['settings']['allowed_values'] = NULL;
+    $this->card_1['settings']['allowed_values_function'] = 'list_test_allowed_values_callback';
+    field_update_field($this->card_1);
+    $instance['required'] = FALSE;
+    field_update_instance($instance);
+
+    // Display form: with no field data, nothing is selected
+    $this->drupalGet('test-entity/' . $entity->ftid .'/edit');
+    $this->assertNoOptionSelected("edit-card-1-$langcode", 0);
+    $this->assertNoOptionSelected("edit-card-1-$langcode", 1);
+    $this->assertNoOptionSelected("edit-card-1-$langcode", 2);
+    $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
+    $this->assertRaw('Group 1', t('Option groups are displayed.'));
+
+    // Submit form: select first option.
+    $edit = array("card_1[$langcode]" => 0);
+    $this->drupalPost(NULL, $edit, t('Save'));
+    $this->assertFieldValues($entity_init, 'card_1', $langcode, array(0));
+
+    // Display form: check that the right options are selected.
+    $this->drupalGet('test-entity/' . $entity->ftid .'/edit');
+    $this->assertOptionSelected("edit-card-1-$langcode", 0);
+    $this->assertNoOptionSelected("edit-card-1-$langcode", 1);
+    $this->assertNoOptionSelected("edit-card-1-$langcode", 2);
+
+    // Submit form: Unselect the option.
+    $edit = array("card_1[$langcode]" => '_none');
+    $this->drupalPost('test-entity/' . $entity->ftid .'/edit', $edit, t('Save'));
+    $this->assertFieldValues($entity_init, 'card_1', $langcode, array());
   }
 
   /**
@@ -277,6 +312,7 @@ class OptionsWidgetsTestCase extends FieldTestCase {
     $this->assertNoOptionSelected("edit-card-2-$langcode", 0);
     $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
     $this->assertNoOptionSelected("edit-card-2-$langcode", 2);
+    $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
 
     // Submit form: select first and third options.
     $edit = array("card_2[$langcode][]" => array(0 => 0, 2 => 2));
@@ -331,6 +367,39 @@ class OptionsWidgetsTestCase extends FieldTestCase {
 
     // We do not have to test that a required select list with one option is
     // auto-selected because the browser does it for us.
+
+    // Test optgroups.
+
+    // Use a callback function defining optgroups.
+    $this->card_2['settings']['allowed_values'] = NULL;
+    $this->card_2['settings']['allowed_values_function'] = 'list_test_allowed_values_callback';
+    field_update_field($this->card_2);
+    $instance['required'] = FALSE;
+    field_update_instance($instance);
+
+    // Display form: with no field data, nothing is selected.
+    $this->drupalGet('test-entity/' . $entity->ftid .'/edit');
+    $this->assertNoOptionSelected("edit-card-2-$langcode", 0);
+    $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
+    $this->assertNoOptionSelected("edit-card-2-$langcode", 2);
+    $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
+    $this->assertRaw('Group 1', t('Option groups are displayed.'));
+
+    // Submit form: select first option.
+    $edit = array("card_2[$langcode][]" => array(0 => 0));
+    $this->drupalPost(NULL, $edit, t('Save'));
+    $this->assertFieldValues($entity_init, 'card_2', $langcode, array(0));
+
+    // Display form: check that the right options are selected.
+    $this->drupalGet('test-entity/' . $entity->ftid .'/edit');
+    $this->assertOptionSelected("edit-card-2-$langcode", 0);
+    $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
+    $this->assertNoOptionSelected("edit-card-2-$langcode", 2);
+
+    // Submit form: Unselect the option.
+    $edit = array("card_2[$langcode][]" => array('_none' => '_none'));
+    $this->drupalPost('test-entity/' . $entity->ftid .'/edit', $edit, t('Save'));
+    $this->assertFieldValues($entity_init, 'card_2', $langcode, array());
   }
 
   /**
@@ -358,6 +427,7 @@ class OptionsWidgetsTestCase extends FieldTestCase {
     // Display form: with no field data, option is unchecked.
     $this->drupalGet('test-entity/' . $entity->ftid .'/edit');
     $this->assertNoFieldChecked("edit-bool-$langcode");
+    $this->assertRaw('Some dangerous &amp; unescaped <strong>markup</strong>', t('Option text was properly filtered.'));
 
     // Submit form: check the option.
     $edit = array("bool[$langcode]" => TRUE);
diff --git a/modules/taxonomy/taxonomy.module b/modules/taxonomy/taxonomy.module
index e21728c9dea1..f17aa1b4e668 100644
--- a/modules/taxonomy/taxonomy.module
+++ b/modules/taxonomy/taxonomy.module
@@ -983,16 +983,6 @@ function taxonomy_field_info() {
 
 /**
  * Implements hook_field_widget_info().
- *
- * We need custom handling of multiple values because we need
- * to combine them into a options list rather than display
- * cardinality elements. We will use the field module's default
- * handling for default values.
- *
- * Callbacks can be omitted if default handing is used.
- * They're included here just so this module can be used
- * as an example for custom modules that might do things
- * differently.
  */
 function taxonomy_field_widget_info() {
   return array(
@@ -1018,6 +1008,13 @@ function taxonomy_field_widget_info_alter(&$info) {
   $info['options_buttons']['field types'][] = 'taxonomy_term';
 }
 
+/**
+ * Implements hook_options_list().
+ */
+function taxonomy_options_list($field) {
+  return taxonomy_allowed_values($field);
+}
+
 /**
  * Implements hook_field_schema().
  */
@@ -1122,12 +1119,12 @@ function taxonomy_field_formatter($object_type, $object, $field, $instance, $lan
 }
 
 /**
- * Create an array of the allowed values for this field.
+ * Returns the set of valid terms for a taxonomy field.
  *
- * Call the field's allowed_values function to retrieve the allowed
- * values array.
- *
- * @see _taxonomy_term_select()
+ * @param $field
+ *   The field definition.
+ * @return
+ *   The array of valid terms for this field, keyed by term id.
  */
 function taxonomy_allowed_values($field) {
   $options = array();
-- 
GitLab