[cml] Add CM and CML schemas

This change adds JSON schemas for CML, the "component manifest
language", and CM, the "binary" component manifest format. CM and CML
are complementary languages to express v2 component manifests. CML is
what humans use to read and write component manifests, while CM is the
format to store a component manifest at rest and is what gets shipped
with a package. CM has a direct one-to-to mapping onto the FIDL
representation.

This change also updates the cmx validator to work with CM and CMl
files. We don't support any post-validation yet, only validation through
the schema.

CF-154 #comment component manifest schemas

TESTED=validate.rs unit tests

Change-Id: Ic22ae05ef0b5c0fbedbbcae8c069b0f6aad458f0
diff --git a/tools/cmx/BUILD.gn b/tools/cmx/BUILD.gn
index 181e9fe..c6816f8 100644
--- a/tools/cmx/BUILD.gn
+++ b/tools/cmx/BUILD.gn
@@ -7,7 +7,9 @@
 
 source_set("cmx_schema_json") {
   inputs = [
-    "cmx_schema.json"
+    "cm_schema.json",
+    "cml_schema.json",
+    "cmx_schema.json",
   ]
 }
 
diff --git a/tools/cmx/cm_schema.json b/tools/cmx/cm_schema.json
new file mode 100644
index 0000000..2be9742
--- /dev/null
+++ b/tools/cmx/cm_schema.json
@@ -0,0 +1,229 @@
+{
+  "type": "object",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "definitions": {
+    "is-capability": {
+      "pattern": "^(service|directory)$"
+    },
+    "is-name": {
+      "pattern": "^[A-Za-z0-9_-]+$"
+    },
+    "has-valid-child": {
+      "oneOf": [
+        { "properties": { "relation": { "not": { "pattern": "child" } } } },
+        { "required": [ "child_name" ] }
+      ]
+    }
+  },
+  "properties": {
+    "program": {
+      "$id": "/properties/program",
+      "type": "object",
+      "title": "Program information"
+    },
+    "uses": {
+      "$id": "/properties/uses",
+      "type": "array",
+      "title": "Used capabilities",
+      "uniqueItems": true,
+      "items": {
+        "$id": "/properties/uses/items/",
+        "type": "object",
+        "required": [
+          "type",
+          "source_path",
+          "target_path"
+        ],
+        "properties": {
+          "type": {
+            "$id": "/properties/uses/items/properties/type",
+            "type": "string",
+            "title": "Used capability type",
+            "$ref": "#/definitions/is-capability"
+          },
+          "source_path": {
+            "$id": "/properties/uses/items/properties/source_path",
+            "type": "string",
+            "title": "Used capability source path"
+          },
+          "target_path": {
+            "$id": "/properties/uses/items/properties/target_path",
+            "type": "string",
+            "title": "Used capability target path"
+          }
+        }
+      }
+    },
+    "exposes": {
+      "$id": "/properties/exposes",
+      "type": "array",
+      "title": "Exposed capabilities",
+      "uniqueItems": true,
+      "items": {
+        "$id": "/properties/exposes/items/",
+        "type": "object",
+        "required": [
+          "type",
+          "source_path",
+          "source",
+          "target_path"
+        ],
+        "properties": {
+          "type": {
+            "$id": "/properties/exposes/items/properties/type",
+            "type": "string",
+            "title": "Exposed capability type",
+            "$ref": "#/definitions/is-capability"
+          },
+          "source_path": {
+            "$id": "/properties/exposes/items/properties/source_path",
+            "type": "string",
+            "title": "Exposed capability source path"
+          },
+          "source": {
+            "$id": "/properties/exposes/items/properties/source",
+            "type": "object",
+            "title": "Exposed capability source component",
+            "required": [
+              "relation"
+            ],
+            "$ref": "#/definitions/has-valid-child",
+            "properties": {
+              "relation": {
+                "$id": "/properties/exposes/items/properties/source/properties/relation",
+                "type": "string",
+                "title": "Exposed capability source component relation",
+                "pattern": "^(self|child)$"
+              },
+              "child_name": {
+                "$id": "/properties/exposes/items/properties/source/properties/child_name",
+                "type": "string",
+                "title": "Exposed capability source component child name",
+                "$ref": "#/definitions/is-name"
+              }
+            }
+          },
+          "target_path": {
+            "$id": "/properties/exposes/items/properties/target_path",
+            "type": "string",
+            "title": "Exposed capability target path"
+          }
+        }
+      }
+    },
+    "offers": {
+      "$id": "/properties/offers",
+      "type": "array",
+      "title": "Offered capabilities",
+      "uniqueItems": true,
+      "items": {
+        "$id": "/properties/offers/items/",
+        "type": "object",
+        "required": [
+          "type",
+          "source_path",
+          "source",
+          "targets"
+        ],
+        "properties": {
+          "type": {
+            "$id": "/properties/offers/items/properties/type",
+            "type": "string",
+            "title": "Offered capability type",
+            "$ref": "#/definitions/is-capability"
+          },
+          "source_path": {
+            "$id": "/properties/exposes/items/properties/source_path",
+            "type": "string",
+            "title": "Offered capability source path"
+          },
+          "source": {
+            "$id": "/properties/exposes/items/properties/source",
+            "type": "object",
+            "title": "Offered capability source component",
+            "required": [
+              "relation"
+            ],
+            "$ref": "#/definitions/has-valid-child",
+            "properties": {
+              "relation": {
+                "$id": "/properties/offers/items/properties/source/properties/relation",
+                "type": "string",
+                "title": "Offered capability source component relation",
+                "pattern": "^(self|realm|child)$"
+              },
+              "child_name": {
+                "$id": "/properties/offers/items/properties/source/properties/child_name",
+                "type": "string",
+                "title": "Offered capability source component child name",
+                "$ref": "#/definitions/is-name"
+              }
+            }
+          },
+          "targets": {
+            "$id": "/properties/offers/items/properties/targets",
+            "type": "array",
+            "title": "Offered capability targets",
+            "uniqueItems": true,
+            "items": {
+              "$id": "/properties/offers/items/properties/targets/items",
+              "type": "object",
+              "required": [
+                "target_path",
+                "child_name"
+              ],
+              "properties": {
+                "target_path": {
+                  "$id": "/properties/offers/items/properties/targets/items/target_path",
+                  "type": "string",
+                  "title": "Offered capability target path"
+                },
+                "child_name": {
+                  "$id": "/properties/offers/items/properties/targets/items/child_name",
+                  "type": "string",
+                  "title": "Offered capability target child name",
+                  "$ref": "#/definitions/is-name"
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "children": {
+      "$id": "/properties/children",
+      "type": "array",
+      "title": "Child components",
+      "uniqueItems": true,
+      "items": {
+        "$id": "/properties/children/items",
+        "type": "object",
+        "required": [
+          "name",
+          "uri"
+        ],
+        "properties": {
+          "name": {
+            "$id": "/properties/children/items/properties/name",
+            "type": "string",
+            "title": "Child component name",
+            "pattern": "^[a-zA-z0-9_-]+$",
+            "$ref": "#/definitions/is-name"
+          },
+          "uri": {
+            "$id": "/properties/children/items/properties/uri",
+            "type": "string",
+            "title": "Child component URI"
+          }
+        }
+      }
+    },
+    "facets": {
+      "$id": "/properties/facets",
+      "type": "object",
+      "title": "Facets",
+      "description": "Freeform dictionary containing third-party metadata"
+    }
+  }
+}
+
diff --git a/tools/cmx/cml_schema.json b/tools/cmx/cml_schema.json
new file mode 100644
index 0000000..c59eb0b
--- /dev/null
+++ b/tools/cmx/cml_schema.json
@@ -0,0 +1,299 @@
+{
+  "type": "object",
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "properties": {
+    "program": {
+      "$id": "/properties/program",
+      "type": "object",
+      "title": "Program information",
+      "description": "Information required to run the program",
+      "required": [
+        "binary"
+      ],
+      "properties": {
+        "binary": {
+          "$id": "/properties/program/properties/binary",
+          "type": "string",
+          "title": "Program binary",
+          "description": "The entry point for an executable",
+          "examples": [
+            "bin/app"
+          ]
+        },
+        "args": {
+          "$id": "/properties/program/properties/args",
+          "type": "array",
+          "title": "Program arguments",
+          "description": "The arguments to provide to an executable",
+          "items": {
+            "type": "string"
+          },
+          "minItems": 1,
+          "examples": [
+            "--verbose"
+          ]
+        },
+        "env": {
+          "$id": "/properties/program/properties/env",
+          "type": "object",
+          "title": "Environment variables",
+          "description": "Environment variables to provide to an executable",
+          "items": {
+            "type": "string"
+          },
+          "minItems": 1,
+          "examples": [
+            {
+              "VERBOSITY": "1"
+            }
+          ]
+        }
+      }
+    },
+    "use": {
+      "$id": "/properties/use",
+      "type": "array",
+      "title": "Used capabilities",
+      "description": "Capabilities that will be installed in this component's namespace",
+      "uniqueItems": true,
+      "items": {
+        "$id": "/properties/use/items",
+        "type": "object",
+        "oneOf": [
+          {
+            "required": [ "service" ]
+          },
+          {
+            "required": [ "directory" ]
+          }
+        ],
+        "properties": {
+          "service": {
+            "$id": "/properties/use/items/properties/service",
+            "type": "string",
+            "title": "Used service source path",
+            "description": "The path under which a service is offered to this component",
+            "examples": [
+              "/svc/fuchsia.logger.Log"
+            ]
+          },
+          "directory": {
+            "$id": "/properties/use/items/properties/directory",
+            "type": "string",
+            "title": "Used directory source path",
+            "description": "The path under which a directory is offered to this component",
+            "examples": [
+              "/data/assets/widgets"
+            ]
+          },
+          "as": {
+            "$id": "/properties/use/items/properties/as",
+            "type": "string",
+            "title": "Used capability target path",
+            "description": "The path to which the capability will be installed in the component's incoming namespace. Defaults to \"service\"/\"directory\".",
+            "examples": [
+              "/svc/fuchsia.logger.Log"
+            ]
+          }
+        }
+      }
+    },
+    "expose": {
+      "$id": "/properties/expose",
+      "type": "array",
+      "title": "Capabilities exposed",
+      "description": "Capabilities exposed by this component to its containing realm",
+      "uniqueItems": true,
+      "items": {
+        "$id": "/properties/expose/items",
+        "type": "object",
+        "required": [
+          "from"
+        ],
+        "oneOf": [
+          {
+            "required": [ "service" ]
+          },
+          {
+            "required": [ "directory" ]
+          }
+        ],
+        "properties": {
+          "service": {
+            "$id": "/properties/expose/items/properties/service",
+            "type": "string",
+            "title": "Exposed service source path",
+            "description": "The path to the service being exposed. This is either a path in this component's outgoing namespace (if from \"self\"), or the path by which the service was exposed by the child (if from \"#$CHILD\").",
+            "examples": [
+              "/svc/fuchsia.ui.Scenic"
+            ]
+          },
+          "directory": {
+            "$id": "/properties/expose/items/properties/directory",
+            "type": "string",
+            "title": "Exposed directory source path",
+            "description": "The path to the directory being exposed. This is either a path in this component's outgoing namespace (if from \"self\"), or the path by which the directory was exposed by the child (if from \"#$CHILD\").",
+            "examples": [
+              "/data/assets/widgets"
+            ]
+          },
+          "from": {
+            "$id": "/properties/expose/items/properties/from",
+            "type": "string",
+            "title": "Exposed capability source component",
+            "pattern": "^(self|#[a-zA-z0-9_-]+)$",
+            "description": "The component which has the capability to expose. Either \"self\" or \"#$CHILD\".",
+            "examples": [
+              "self",
+              "#scenic"
+            ]
+          },
+          "as": {
+            "$id": "/properties/expose/items/properties/as",
+            "type": "string",
+            "title": "Exposed capability target path",
+            "description": "The path under which the capability will be exposed. Defaults to \"service\"/\"directory\"",
+            "examples": [
+              "/svc/fuchsia.logger.Log"
+            ]
+          }
+        }
+      }
+    },
+    "offer": {
+      "$id": "/properties/offer",
+      "type": "array",
+      "title": "Offered capabilitys",
+      "description": "Capabilities offered by this component to its children",
+      "uniqueItems": true,
+      "items": {
+        "$id": "/properties/offer/items",
+        "type": "object",
+        "required": [
+          "from",
+          "targets"
+        ],
+        "oneOf": [
+          {
+            "required": [ "service" ]
+          },
+          {
+            "required": [ "directory" ]
+          }
+        ],
+        "properties": {
+          "service": {
+            "$id": "/properties/offer/items/properties/service",
+            "type": "string",
+            "title": "Offered service source path",
+            "description": "The path to the service being offered. This is either a path in this component's namespace (if from \"self\"), or the path by which the service was exposed or offered from another component (if from \"realm\" or \"#$CHILD\").",
+            "examples": [
+              "/svc/fuchsia.ui.Scenic"
+            ]
+          },
+          "directory": {
+            "$id": "/properties/offer/items/properties/directory",
+            "type": "string",
+            "title": "Offered directory source path",
+            "description": "The path to the directory being offered. This is either a path in this component's outgoing namespace (if from \"self\"), or the path by which the directory was exposed or offered from another component (if from \"realm\" or \"#$CHILD\").",
+            "examples": [
+              "/svc/fuchsia.ui.Scenic"
+            ]
+          },
+          "from": {
+            "$id": "/properties/offer/items/properties/from",
+            "type": "string",
+            "title": "Offered capability source component",
+            "description": "The component which has the capability to offer. Either \"realm\", \"self\" or \"#$CHILD\".",
+            "pattern": "^(realm|self|#[A-Za-z0-9_-]+)$",
+            "examples": [
+              "realm",
+              "self",
+              "#scenic"
+            ]
+          },
+          "targets": {
+            "$id": "/properties/offer/items/properties/targets",
+            "type": "array",
+            "title": "Offered capability targets",
+            "description": "The components the capability is being offered to",
+            "uniqueItems": true,
+            "minItems": 1,
+            "items": {
+              "$id": "/properties/offer/items/properties/targets/items",
+              "type": "object",
+              "required": [
+                "to"
+              ],
+              "properties": {
+                "to": {
+                  "$id": "/properties/offer/items/properties/targets/items/properties/to",
+                  "type": "string",
+                  "title": "Offered capability target component",
+                  "description": "The child component to which the capability is being offered, with the syntax \"#$CHILD\".",
+                  "pattern": "^#[a-zA-Z0-9_-]+$",
+                  "examples": [
+                    "#scenic"
+                  ]
+                },
+                "as": {
+                  "$id": "/properties/offer/items/properties/targets/items/properties/as",
+                  "type": "string",
+                  "title": "Offered capability target path",
+                  "description": "The path by which the capability will be offered. The path is either a path in this component's namespace (if from \"self\"), or the path by which the capability was exposed or offered from another component (if from \"realm\" or \"#$CHILD\").",
+                  "examples": [
+                    "/data/assets/widgets",
+                    "/svc/fuchsia.ui.Scenic"
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "children": {
+      "$id": "/properties/children",
+      "type": "array",
+      "title": "Child components",
+      "description": "The children of this component, including name and launch information",
+      "uniqueItems": true,
+      "items": {
+        "$id": "/properties/children/items",
+        "type": "object",
+        "required": [
+          "name",
+          "uri"
+        ],
+        "properties": {
+          "name": {
+            "$id": "/properties/children/items/properties/name",
+            "type": "string",
+            "title": "Child component name",
+            "description": "The parent's local name for the child",
+            "pattern": "^[a-zA-z0-9_-]+$",
+            "examples": [
+              "echo2_server",
+              "System-logger"
+            ]
+          },
+          "uri": {
+            "$id": "/properties/children/items/properties/uri",
+            "type": "string",
+            "title": "Child component URI",
+            "description": "The URI that identifies the child component.",
+            "examples": [
+              "fuchsia-pkg://fuchsia.com/echo_server_cpp#meta/echo_server.cml"
+            ]
+          }
+        }
+      }
+    },
+    "facets": {
+      "$id": "/properties/facets",
+      "type": "object",
+      "title": "Facets",
+      "description": "Freeform dictionary containing third-party metadata"
+    }
+  }
+}
diff --git a/tools/cmx/src/common.rs b/tools/cmx/src/common.rs
index 7a3251a..bfc6cd8 100644
--- a/tools/cmx/src/common.rs
+++ b/tools/cmx/src/common.rs
@@ -7,9 +7,14 @@
 use std::io;
 use std::str::Utf8Error;
 
-// Directly include schema in the binary. This is used to parse component manifests.
+// Directly include schemas in the binary. These are used to parse component manifests.
+pub const CM_SCHEMA: &str = include_str!("../cm_schema.json");
+pub const CML_SCHEMA: &str = include_str!("../cml_schema.json");
 pub const CMX_SCHEMA: &str = include_str!("../cmx_schema.json");
 
+/// Represents a JSON schema.
+pub type JsonSchemaStr<'a> = &'a str;
+
 /// Enum type that can represent any error encountered by a cmx operation.
 #[derive(Debug)]
 pub enum Error {
diff --git a/tools/cmx/src/validate.rs b/tools/cmx/src/validate.rs
index afb4833..18268f5 100644
--- a/tools/cmx/src/validate.rs
+++ b/tools/cmx/src/validate.rs
@@ -2,19 +2,21 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-use crate::common::{Error, CMX_SCHEMA};
+use crate::common::{Error, CM_SCHEMA, CML_SCHEMA, CMX_SCHEMA, JsonSchemaStr};
 use serde_json::Value;
 use std::fs;
 use std::io::Read;
 use std::path::PathBuf;
 use valico::json_schema;
 
-/// read in and parse a list of files, and return an Error if any of the given files are not valid
-/// cmx. The jsonschema file located at ../schema.json is used to determine the validity of the cmx
-/// files.
+/// Read in and parse a list of files, and return an Error if any of the given files are not valid
+/// cmx. One of the JSON schemas located at ../*_schema.json, selected based on the file extension,
+/// is used to determine the validity of each input file.
 pub fn validate(files: Vec<PathBuf>) -> Result<(), Error> {
+    const BAD_EXTENSION: &str = "Input file does not have a component manifest extension \
+                                 (.cm, .cml, or .cmx)";
     if files.is_empty() {
-        return Err(Error::invalid_args("no files provided"));
+        return Err(Error::invalid_args("No files provided"));
     }
 
     for filename in files {
@@ -22,14 +24,23 @@
         fs::File::open(&filename)?.read_to_string(&mut buffer)?;
         let v: Value = serde_json::from_str(&buffer)
             .map_err(|e| Error::parse(format!("Couldn't read input as JSON: {}", e)))?;
-        validate_json(&v)?;
+        match filename.extension() {
+            Some(ext) => match ext.to_str() {
+                Some("cm") => validate_json(&v, CM_SCHEMA),
+                Some("cml") => validate_cml(&v),
+                Some("cmx") => validate_json(&v, CMX_SCHEMA),
+                _ => Err(Error::invalid_args(BAD_EXTENSION)),
+            },
+            None => Err(Error::invalid_args(BAD_EXTENSION)),
+        }?;
     }
     Ok(())
 }
 
-fn validate_json(json: &Value) -> Result<(), Error> {
+/// Validate a JSON document according to the given schema.
+fn validate_json(json: &Value, schema: JsonSchemaStr) -> Result<(), Error> {
     // Parse the schema
-    let cmx_schema_json = serde_json::from_str(CMX_SCHEMA)
+    let cmx_schema_json = serde_json::from_str(schema)
         .map_err(|e| Error::internal(format!("Couldn't read schema as JSON: {}", e)))?;
     let mut scope = json_schema::Scope::new();
     let schema = scope
@@ -51,6 +62,13 @@
     Ok(())
 }
 
+/// Validates CML JSON document according to the schema.
+/// TODO: Allow JSON5 input
+/// TODO: Perform extra validation beyond what the schema provides.
+pub fn validate_cml(json: &Value) -> Result<(), Error> {
+    validate_json(&json, CML_SCHEMA)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -59,6 +77,42 @@
     use std::io::Write;
     use tempfile::TempDir;
 
+    macro_rules! test_validate_cm {
+        (
+            $(
+                $test_name:ident => {
+                    input = $input:expr,
+                    result = $result:expr,
+                },
+            )+
+        ) => {
+            $(
+                #[test]
+                fn $test_name() {
+                    validate_test("test.cm", $input, $result);
+                }
+            )+
+        }
+    }
+
+    macro_rules! test_validate_cml {
+        (
+            $(
+                $test_name:ident => {
+                    input = $input:expr,
+                    result = $result:expr,
+                },
+            )+
+        ) => {
+            $(
+                #[test]
+                fn $test_name() {
+                    validate_test("test.cml", $input, $result);
+                }
+            )+
+        }
+    }
+
     macro_rules! test_validate_cmx {
         (
             $(
@@ -92,6 +146,737 @@
         assert_eq!(format!("{:?}", result), format!("{:?}", expected_result));
     }
 
+    // TODO: Consider converting these tests to a golden test
+
+    test_validate_cm! {
+        // program
+        test_cm_empty_json => {
+            input = json!({}),
+            result = Ok(()),
+        },
+        test_cm_program => {
+            input = json!({"program": { "foo": 55 }}),
+            result = Ok(()),
+        },
+
+        // uses
+        test_cm_uses => {
+            input = json!({
+                "uses": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.boot.Log",
+                        "target_path": "/svc/fuchsia.logger.Log",
+                    },
+                    {
+                        "type": "directory",
+                        "source_path": "/data/assets",
+                        "target_path": "/data/kitten_assets",
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cm_uses_missing_props => {
+            input = json!({
+                "uses": [ {} ]
+            }),
+            result = Err(Error::parse("This property is required at /uses/0/source_path, This property is required at /uses/0/target_path, This property is required at /uses/0/type")),
+        },
+        test_cm_uses_bad_type => {
+            input = json!({
+                "uses": [
+                    {
+                        "type": "bad",
+                        "source_path": "/svc/fuchsia.logger.Log",
+                        "target_path": "/svc/fuchsia.logger.Log",
+                    }
+                ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /uses/0/type")),
+        },
+
+        // exposes
+        test_cm_exposes => {
+            input = json!({
+                "exposes": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "self"
+                        },
+                        "target_path": "/svc/fuchsia.ui.Scenic"
+                    },
+                    {
+                        "type": "directory",
+                        "source_path": "/data/assets",
+                        "source": {
+                            "relation": "child",
+                            "child_name": "cat_viewer"
+                        },
+                        "target_path": "/data/kitten_assets"
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cm_exposes_all_valid_chars => {
+            input = json!({
+                "exposes": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "child",
+                            "child_name": "ABCDEFGHIJILMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
+                        },
+                        "target_path": "/svc/fuchsia.ui.Scenic"
+                    },
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cm_exposes_missing_props => {
+            input = json!({
+                "exposes": [ {} ]
+            }),
+            result = Err(Error::parse("This property is required at /exposes/0/source, This property is required at /exposes/0/source_path, This property is required at /exposes/0/target_path, This property is required at /exposes/0/type")),
+        },
+        test_cm_exposes_bad_type => {
+            input = json!({
+                "exposes": [
+                    {
+                        "type": "bad",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "self"
+                        },
+                        "target_path": "/svc/fuchsia.ui.Scenic"
+                    }
+                ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /exposes/0/type")),
+        },
+        test_cm_exposes_source_missing_props => {
+            input = json!({
+                "exposes": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {},
+                        "target_path": "/svc/fuchsia.ui.Scenic"
+                    }
+                ]
+            }),
+            result = Err(Error::parse("This property is required at /exposes/0/source/relation")),
+        },
+        test_cm_exposes_source_extraneous_child => {
+            input = json!({
+                "exposes": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": { "relation": "self", "child_name": "scenic" },
+                        "target_path": "/svc/fuchsia.ui.Scenic"
+                    }
+                ]
+            }),
+            result = Err(Error::parse("OneOf conditions are not met at /exposes/0/source")),
+        },
+        test_cm_exposes_source_missing_child => {
+            input = json!({
+                "exposes": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": { "relation": "child" },
+                        "target_path": "/svc/fuchsia.ui.Scenic"
+                    }
+                ]
+            }),
+            result = Err(Error::parse("OneOf conditions are not met at /exposes/0/source")),
+        },
+        test_cm_exposes_source_bad_relation => {
+            input = json!({
+                "exposes": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "realm"
+                        },
+                        "target_path": "/svc/fuchsia.ui.Scenic"
+                    }
+                ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /exposes/0/source/relation")),
+        },
+        test_cm_exposes_source_bad_child_name => {
+            input = json!({
+                "exposes": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "child",
+                            "child_name": "bad^"
+                        },
+                        "target_path": "/svc/fuchsia.ui.Scenic"
+                    }
+                ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /exposes/0/source/child_name")),
+        },
+
+        // offers
+        test_cm_offers => {
+            input = json!({
+                "offers": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.logger.LogSink",
+                        "source": {
+                            "relation": "realm"
+                        },
+                        "targets": [
+                            {
+                                "target_path": "/svc/fuchsia.logger.SysLog",
+                                "child_name": "viewer"
+                            }
+                        ]
+                    },
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "self"
+                        },
+                        "targets": [
+                            {
+                                "target_path": "/svc/fuchsia.ui.Scenic",
+                                "child_name": "user_shell"
+                            },
+                            {
+                                "target_path": "/services/fuchsia.ui.Scenic",
+                                "child_name": "viewer"
+                            }
+                        ]
+                    },
+                    {
+                        "type": "directory",
+                        "source_path": "/data/assets",
+                        "source": {
+                            "relation": "child",
+                            "child_name": "cat_provider"
+                        },
+                        "targets": [
+                            {
+                                "target_path": "/data/kitten_assets",
+                                "child_name": "cat_viewer"
+                            }
+                        ]
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cm_offers_all_valid_chars => {
+            input = json!({
+                "offers": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.logger.LogSink",
+                        "source": {
+                            "relation": "child",
+                            "child_name": "ABCDEFGHIJILMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
+                        },
+                        "targets": [
+                            {
+                                "target_path": "/svc/fuchsia.logger.SysLog",
+                                "child_name": "ABCDEFGHIJILMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
+                            }
+                        ]
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cm_offers_missing_props => {
+            input = json!({
+                "offers": [ {} ]
+            }),
+            result = Err(Error::parse("This property is required at /offers/0/source, This property is required at /offers/0/source_path, This property is required at /offers/0/targets, This property is required at /offers/0/type")),
+        },
+        test_cm_offers_bad_type => {
+            input = json!({
+                "offers": [
+                    {
+                        "type": "bad",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "self"
+                        },
+                        "targets": [
+                            {
+                                "target_path": "/svc/fuchsia.ui.Scenic",
+                                "child_name": "user_shell"
+                            }
+                        ]
+                    }
+                ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /offers/0/type")),
+        },
+        test_cm_offers_source_missing_props => {
+            input = json!({
+                "offers": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {},
+                        "targets": [
+                            {
+                                "target_path": "/svc/fuchsia.ui.Scenic",
+                                "child_name": "user_shell"
+                            }
+                        ]
+                    }
+                ]
+            }),
+            result = Err(Error::parse("This property is required at /offers/0/source/relation")),
+        },
+        test_cm_offers_source_extraneous_child => {
+            input = json!({
+                "offers": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": { "relation": "self", "child_name": "scenic" },
+                        "targets": [
+                            {
+                                "target_path": "/svc/fuchsia.ui.Scenic",
+                                "child_name": "user_shell"
+                            }
+                        ]
+                    }
+                ]
+            }),
+            result = Err(Error::parse("OneOf conditions are not met at /offers/0/source")),
+        },
+        test_cm_offers_source_missing_child => {
+            input = json!({
+                "offers": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": { "relation": "child" },
+                        "targets": [
+                            {
+                                "target_path": "/svc/fuchsia.ui.Scenic",
+                                "child_name": "user_shell"
+                            }
+                        ]
+                    }
+                ]
+            }),
+            result = Err(Error::parse("OneOf conditions are not met at /offers/0/source")),
+        },
+        test_cm_offers_source_bad_relation => {
+            input = json!({
+                "offers": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "bad"
+                        },
+                        "targets": [
+                            {
+                                "target_path": "/svc/fuchsia.ui.Scenic",
+                                "child_name": "user_shell"
+                            }
+                        ]
+                    }
+                ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /offers/0/source/relation")),
+        },
+        test_cm_offers_source_bad_child_name => {
+            input = json!({
+                "offers": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "child",
+                            "child_name": "bad^"
+                        },
+                        "targets": [
+                            {
+                                "target_path": "/svc/fuchsia.ui.Scenic",
+                                "child_name": "user_shell"
+                            }
+                        ]
+                    }
+                ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /offers/0/source/child_name")),
+        },
+        test_cm_offers_target_missing_props => {
+            input = json!({
+                "offers": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "child",
+                            "child_name": "cat_viewer"
+                        },
+                        "targets": [ {} ]
+                    }
+                ]
+            }),
+            result = Err(Error::parse("This property is required at /offers/0/targets/0/child_name, This property is required at /offers/0/targets/0/target_path")),
+        },
+        test_cm_offers_target_bad_child_name => {
+            input = json!({
+                "offers": [
+                    {
+                        "type": "service",
+                        "source_path": "/svc/fuchsia.ui.Scenic",
+                        "source": {
+                            "relation": "self"
+                        },
+                        "targets": [
+                            {
+                                "target_path": "/svc/fuchsia.ui.Scenic",
+                                "child_name": "bad^"
+                            }
+                        ]
+                    }
+                ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /offers/0/targets/0/child_name")),
+        },
+
+        // children
+        test_cm_children => {
+            input = json!({
+                "children": [
+                    {
+                        "name": "System-logger2",
+                        "uri": "fuchsia-pkg://fuchsia.com/logger/stable#meta/logger.cm"
+                    },
+                    {
+                        "name": "ABCabc123_-",
+                        "uri": "https://www.google.com/gmail"
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cm_children_all_valid_chars => {
+            input = json!({
+                "children": [
+                    {
+                        "name": "ABCDEFGHIJILMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-",
+                        "uri": "https://www.google.com/gmail"
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cm_children_missing_props => {
+            input = json!({
+                "children": [ {} ]
+            }),
+            result = Err(Error::parse("This property is required at /children/0/name, This property is required at /children/0/uri")),
+        },
+        test_cm_children_bad_name => {
+            input = json!({
+                "children": [
+                    {
+                        "name": "bad^",
+                        "uri": "fuchsia-pkg://fuchsia.com/logger/stable#meta/logger.cm"
+                    }
+                ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /children/0/name")),
+        },
+
+        // facets
+        test_cm_facets => {
+            input = json!({
+                "facets": {
+                    "metadata": {
+                        "title": "foo",
+                        "authors": [ "me", "you" ],
+                        "year": 2018
+                    }
+                }
+            }),
+            result = Ok(()),
+        },
+        test_cm_facets_wrong_type => {
+            input = json!({
+                "facets": 55
+            }),
+            result = Err(Error::parse("Type of the value is wrong at /facets")),
+        },
+    }
+
+    test_validate_cml! {
+        // program
+        test_cml_empty_json => {
+            input = json!({}),
+            result = Ok(()),
+        },
+        test_cml_program => {
+            input = json!({"program": { "binary": "bin/app" }}),
+            result = Ok(()),
+        },
+        test_cml_program_no_binary => {
+            input = json!({"program": {}}),
+            result = Err(Error::parse("This property is required at /program/binary")),
+        },
+
+        // use
+        test_cml_use => {
+            input = json!({
+                "use": [
+                  { "service": "/fonts/CoolFonts", "as": "/svc/fuchsia.fonts.Provider" },
+                  { "directory": "/data/assets" }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cml_use_missing_props => {
+            input = json!({
+                "use": [ { "as": "/svc/fuchsia.logger.Log" } ]
+            }),
+            result = Err(Error::parse("OneOf conditions are not met at /use/0")),
+        },
+
+        // expose
+        test_cml_expose => {
+            input = json!({
+                "expose": [
+                    { "service": "/loggers/fuchsia.logger.Log", "from": "#logger" },
+                    { "directory": "/volumes/blobfs", "from": "self" }
+                ],
+                "children": [
+                    {
+                        "name": "logger",
+                        "uri": "fuchsia-pkg://fuchsia.com/logger/stable#meta/logger.cm"
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cml_expose_all_valid_chars => {
+            input = json!({
+                "expose": [
+                    { "service": "/loggers/fuchsia.logger.Log", "from": "#ABCDEFGHIJILMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" }
+                ],
+                "children": [
+                    {
+                        "name": "ABCDEFGHIJILMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-",
+                        "uri": "https://www.google.com/gmail"
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cml_expose_missing_props => {
+            input = json!({
+                "expose": [ {} ]
+            }),
+            result = Err(Error::parse("OneOf conditions are not met at /expose/0, This property is required at /expose/0/from")),
+        },
+        test_cml_expose_bad_from => {
+            input = json!({
+                "expose": [ {
+                    "service": "/loggers/fuchsia.logger.Log", "from": "realm"
+                } ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /expose/0/from")),
+        },
+
+        // offer
+        test_cml_offer => {
+            input = json!({
+                "offer": [
+                    {
+                        "service": "/svc/fuchsia.logger.Log",
+                        "from": "#logger",
+                        "targets": [
+                            { "to": "#echo2_server" },
+                            { "to": "#scenic", "as": "/svc/fuchsia.logger.SysLog" }
+                        ]
+                    },
+                    {
+                        "service": "/svc/fuchsia.fonts.Provider",
+                        "from": "realm",
+                        "targets": [
+                            { "to": "#echo2_server" },
+                        ]
+                    },
+                    {
+                        "directory": "/data/assets",
+                        "from": "self",
+                        "targets": [
+                            { "to": "#echo2_server" },
+                        ]
+                    }
+                ],
+                "children": [
+                    {
+                        "name": "logger",
+                        "uri": "fuchsia-pkg://fuchsia.com/logger/stable#meta/logger.cm"
+                    },
+                    {
+                        "name": "scenic",
+                        "uri": "fuchsia-pkg://fuchsia.com/scenic/stable#meta/scenic.cm"
+                    },
+                    {
+                        "name": "echo2_server",
+                        "uri": "fuchsia-pkg://fuchsia.com/echo2/stable#meta/echo2_server.cm"
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cml_offer_all_valid_chars => {
+            input = json!({
+                "offer": [
+                    {
+                        "service": "/svc/fuchsia.logger.Log",
+                        "from": "#ABCDEFGHIJILMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-",
+                        "targets": [
+                            {
+                                "to": "#ABCDEFGHIJILMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
+                            }
+                        ]
+                    }
+                ],
+                "children": [
+                    {
+                        "name": "ABCDEFGHIJILMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-",
+                        "uri": "https://www.google.com/gmail"
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cml_offer_missing_props => {
+            input = json!({
+                "offer": [ {} ]
+            }),
+            result = Err(Error::parse("OneOf conditions are not met at /offer/0, This property is required at /offer/0/from, This property is required at /offer/0/targets")),
+        },
+        test_cml_offer_bad_from => {
+            input = json!({
+                    "offer": [ {
+                        "service": "/svc/fuchsia.logger.Log",
+                        "from": "#invalid@",
+                        "targets": [
+                            { "to": "#echo2_server" },
+                        ]
+                    } ]
+                }),
+            result = Err(Error::parse("Pattern condition is not met at /offer/0/from")),
+        },
+        test_cml_offer_empty_targets => {
+            input = json!({
+                "offer": [ {
+                    "service": "/svc/fuchsia.logger.Log",
+                    "from": "#logger",
+                    "targets": []
+                } ]
+            }),
+            result = Err(Error::parse("MinItems condition is not met at /offer/0/targets")),
+        },
+        test_cml_offer_target_missing_props => {
+            input = json!({
+                "offer": [ {
+                    "service": "/svc/fuchsia.logger.Log",
+                    "from": "#logger",
+                    "targets": [
+                        { "as": "/svc/fuchsia.logger.SysLog" }
+                    ]
+                } ]
+            }),
+            result = Err(Error::parse("This property is required at /offer/0/targets/0/to")),
+        },
+        test_cml_offer_target_bad_to => {
+            input = json!({
+                "offer": [ {
+                    "service": "/svc/fuchsia.logger.Log",
+                    "from": "#logger",
+                    "targets": [
+                        { "to": "self", "as": "/svc/fuchsia.logger.SysLog" }
+                    ]
+                } ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /offer/0/targets/0/to")),
+        },
+
+        // children
+        test_cml_children => {
+            input = json!({
+                "children": [
+                    {
+                        "name": "logger",
+                        "uri": "fuchsia-pkg://fuchsia.com/logger/stable#meta/logger.cm"
+                    },
+                    {
+                        "name": "gmail",
+                        "uri": "https://www.google.com/gmail"
+                    }
+                ]
+            }),
+            result = Ok(()),
+        },
+        test_cml_children_missing_props => {
+            input = json!({
+                "children": [ {} ]
+            }),
+            result = Err(Error::parse("This property is required at /children/0/name, This property is required at /children/0/uri")),
+        },
+        test_cml_children_bad_name => {
+            input = json!({
+                "children": [
+                    {
+                        "name": "#bad",
+                        "uri": "fuchsia-pkg://fuchsia.com/logger/stable#meta/logger.cm"
+                    }
+                ]
+            }),
+            result = Err(Error::parse("Pattern condition is not met at /children/0/name")),
+        },
+
+        // facets
+        test_cml_facets => {
+            input = json!({
+                "facets": {
+                    "metadata": {
+                        "title": "foo",
+                        "authors": [ "me", "you" ],
+                        "year": 2018
+                    }
+                }
+            }),
+            result = Ok(()),
+        },
+        test_cml_facets_wrong_type => {
+            input = json!({
+                "facets": 55
+            }),
+            result = Err(Error::parse("Type of the value is wrong at /facets")),
+        },
+    }
+
     test_validate_cmx! {
         test_cmx_err_empty_json => {
             input = json!({}),