├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── index.js ├── package.json ├── run.js ├── schema.js └── test ├── data ├── include-pass.json ├── include-target-fail.json ├── invalid-byte-order-mark.json ├── missing-target-namespace-fail.json ├── multiple-include-fail.json ├── simple-fail.json ├── simple-pass.json └── unkown-target-namespace-fail.json └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to become a contributor and submit your own code 2 | 3 | ## Contributor License Agreements 4 | 5 | We'd love to accept your sample apps and patches! Before we can take them, we 6 | have to jump a couple of legal hurdles. 7 | 8 | Please fill out either the individual or corporate Contributor License Agreement (CLA). 9 | 10 | * If you are an individual writing original source code and you're sure you 11 | own the intellectual property, then you'll need to sign an [individual CLA] 12 | (https://cla.developers.google.com). 13 | * If you work for a company that wants to allow you to contribute your work, 14 | then you'll need to sign a [corporate CLA] 15 | (https://cla.developers.google.com). 16 | 17 | Follow either of the two links above to access the appropriate CLA and 18 | instructions for how to sign and return it. Once we receive it, we'll be able to 19 | accept your pull requests. 20 | 21 | ## Contributing A Patch 22 | 23 | 1. Submit an issue describing your proposed change to the repo in question. 24 | 1. The repo owner will respond to your issue promptly. 25 | 1. If your proposed change is accepted, and you haven't already done so, sign a 26 | Contributor License Agreement (see details above). 27 | 1. Fork the desired repo, develop and test your code changes. 28 | 1. Ensure that your code has an appropriate set of unit tests which all pass. 29 | 1. Submit a pull request. 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Asset Check 2 | Check your assetlinks.json file for associations, ensuring you're configured correctly. 3 | 4 | *This is not an official Google product.* 5 | 6 | ## Install 7 | Install the node dependencies and link the executable: 8 | ``` 9 | npm install 10 | npm link 11 | ``` 12 | 13 | ## Usage 14 | If you've linked the package you can use: 15 | ``` 16 | asset-check # check a file 17 | asset-check # check a url 18 | 19 | asset-check -u # check a url with a specific useragent 20 | asset-check -d # (debug mode) check a url 21 | ``` 22 | 23 | Or in your install directory: 24 | ``` 25 | ./run.js # check a file 26 | ./run.js # check a url 27 | 28 | ./run.js -d # check a url with a specific useragent 29 | ``` 30 | 31 | ### Examples 32 | ``` 33 | > asset-check -d assetlinks.json 34 | instance[1].target: is not exactly one from , 35 | Errors validating schema 36 | 37 | › asset-check https://www.google.com/.well-known/assetlinks.json 38 | URL: https://www.google.com/.well-known/assetlinks.json 39 | # App Links: 40 | ## Websites linked: 41 | - www.google.com 42 | ## To apps: 43 | - com.google.android.calendar 44 | ``` 45 | 46 | ## Tests 47 | ```npm test``` 48 | 49 | ## Release History 50 | * 0.1.0 Initial release 51 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 'use strict'; 17 | 18 | const fs = require('fs'); 19 | const colors = require('colors'); 20 | const url = require('url'); 21 | const https = require('https'); 22 | const jsonschema = require('jsonschema'); 23 | const schema = require('./schema').assetlinksSchema; 24 | 25 | const DEFAULT_ENCODING = 'utf8'; 26 | const DEFAULT_USER_AGENT = 27 | 'user-agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.133 Safari/537.36'; 28 | 29 | const LOG_LEVELS = { 30 | SILENT: 0, 31 | INFO: 1, 32 | DEBUG: 2 33 | }; 34 | 35 | /** 36 | * The AssetCheck main class 37 | * 38 | * Exposes the features of this application. 39 | * 40 | * @type {AssetCheck} 41 | */ 42 | class AssetCheck { 43 | 44 | /** 45 | * Create the application 46 | * @param {string} assetFile the assetlinks.json file to consume 47 | * (local or hosted) 48 | * @param {int} logLevel see LOG_LEVELS for list of supported levels 49 | * @param {string} userAgent the user agent to request the file 50 | * @return {AssetCheck} the configured AssetCheck 51 | */ 52 | constructor(assetFile, logLevel, userAgent) { 53 | this.filename = assetFile; 54 | this.assetFile = new AssetFile(assetFile); 55 | this.hostname = false; 56 | this.hasErrors = false; 57 | this.logLevel = (logLevel == undefined) ? LOG_LEVELS.INFO : logLevel; 58 | this.validator = new jsonschema.Validator(); 59 | this.schema = null; 60 | for (var key in schema) { 61 | if (this.schema == null) { 62 | this.schema = schema[key]; 63 | } 64 | this.validator.addSchema(schema[key], key); 65 | } 66 | 67 | this.userAgent = (userAgent == undefined) ? DEFAULT_USER_AGENT : userAgent; 68 | } 69 | 70 | /** 71 | * Test the assetFile and display the output 72 | */ 73 | run() { 74 | var uri = url.parse(this.filename); 75 | 76 | if (this.assetFile.isLocal()) { 77 | this.assetFile.getLocal((data) => { 78 | this.handleParseJson( 79 | data, (obj) => this.displayAssociations(obj), 80 | (err) => this.fatal('Errors with file contents: ' + err)); 81 | }, (err) => { 82 | this.fatal('Unable to get contents of file', err); 83 | }); 84 | } else { 85 | this.logDebug('User agent: ' + this.userAgent); 86 | this.logInfo('URL: '.green + this.assetFile.getFilename()); 87 | this.assetFile.getRemote((data) => { 88 | this.handleHttpRequest(data); 89 | }, this.userAgent); 90 | } 91 | } 92 | 93 | /** 94 | * Handle a hosted assetlinks.json file 95 | * 96 | * @param {http.IncomingMessage} res the incoming message to process 97 | */ 98 | handleHttpRequest(res) { 99 | let statusCode = res.statusCode; 100 | let contentType = res.headers['content-type']; 101 | 102 | if (statusCode != 200) { 103 | let additional = '' 104 | if ('location' in res.headers) { 105 | additional += ' [ ' + res.headers['location'] + ' ]'; 106 | } 107 | this.fatal('Bad response code: ' + statusCode + additional.yellow); 108 | } 109 | if (contentType.indexOf('application/json') != 0) { 110 | this.fatal('Bad response Content-Type: ' + contentType); 111 | } 112 | 113 | var body = ''; 114 | res.on('data', (chunk) => { 115 | body += chunk; 116 | }); 117 | res.on('end', () => { 118 | this.handleParseJson( 119 | body, (data) => this.displayAssociations(data), (err) => { 120 | this.fatal(err); 121 | }); 122 | }) 123 | } 124 | 125 | /** 126 | * Parse the string of json 127 | * 128 | * @param {string} data the string representation of the json 129 | * @param {Function} succeed the function to be called with the succesfully 130 | * parsed json 131 | * @param {Function} fail the function to be called when errors are found 132 | */ 133 | handleParseJson(data, succeed, fail) { 134 | let obj; 135 | try { 136 | obj = JSON.parse(data); 137 | } catch (err) { 138 | // JSON parse error; do some further analysis to provide better error 139 | // messages 140 | 141 | // JSON does not allow a byte order mark 142 | if (err.message.startsWith( 143 | 'Unexpected token \ufeff in JSON at position 0')) { 144 | return fail('File must be UTF-8 encoded _without_ a byte order mark (BOM)'); 145 | } 146 | 147 | 148 | return fail('Unable to parse json' + err); 149 | } 150 | if (Object.keys(obj).length < 1) { 151 | return fail('No data in file.'); 152 | } 153 | let results = this.validator.validate(obj, this.schema); 154 | if (results.errors.length > 0) { 155 | for (var e in results.errors) { 156 | var err = results.errors[e]; 157 | this.err(err.property + ': ' + err.message); 158 | } 159 | return fail('Errors validating schema'); 160 | } 161 | succeed(obj); 162 | } 163 | 164 | /** 165 | * Display any web & app associations 166 | * 167 | * @param {Object} data the parsed json 168 | */ 169 | displayAssociations(data) { 170 | var creds = {'web': [], 'android': []}; 171 | var links = {'web': [], 'android': []}; 172 | if (data.length == 1 && IncludeEntry.isEntry(data[0])) { 173 | try { 174 | var entry = new IncludeEntry(data[0]); 175 | this.logInfo('# \u2713 Includes File'.green); 176 | this.logDebug('## URL: ' + entry.getInclude()); 177 | } catch (err) { 178 | this.err('[entry] ' + err); 179 | } 180 | return; 181 | } 182 | for (let item of Object.keys(data)) { 183 | try { 184 | var entry = new AssetEntry(data[item]); 185 | var relation = entry.getRelation(); 186 | var target = entry.getTarget(); 187 | if (target.isWeb()) { 188 | if (relation.hasGetLoginCreds()) { 189 | creds['web'].push(target.getSite()); 190 | } 191 | if (relation.hasHandleAllUrls()) { 192 | links['web'].push(target.getSite()); 193 | } 194 | } else { 195 | var appData = target.getAndroidData(); 196 | if (relation.hasGetLoginCreds()) { 197 | creds['android'].push(appData['package_name']); 198 | } 199 | if (relation.hasHandleAllUrls()) { 200 | links['android'].push(appData['package_name']); 201 | } 202 | } 203 | } catch (err) { 204 | this.err('[entry] ' + err); 205 | continue; 206 | } 207 | } 208 | var displayed = false; 209 | if (creds['web'].length > 0 && creds['android'].length > 0) { 210 | displayed = true; 211 | this.logInfo('# \u2713 Smart Lock'.green); 212 | this.logDebug('## Websites linked:\n- ' + creds['web'].join('\n- ')); 213 | this.logDebug('## To apps:\n- ' + creds['android'].join('\n- ')); 214 | } 215 | if (links['web'].length > 0 && links['android'].length > 0) { 216 | displayed = true; 217 | this.logInfo('# \u2713 App Links'.green); 218 | this.logDebug('## Websites linked:\n- ' + links['web'].join('\n- ')); 219 | this.logDebug('## To apps:\n- ' + links['android'].join('\n- ')); 220 | } else if (links['android'].length > 0) { 221 | displayed = true; 222 | this.logInfo('# \u2713 App Links'.green); 223 | if (!this.hostname) { 224 | this.logDebug('## Current website'); 225 | } else { 226 | this.logDebug('## Websites linked:\n- ' + this.hostname); 227 | } 228 | this.logDebug('## To apps:\n- ' + links['android'].join('\n- ')); 229 | } 230 | if (!displayed) { 231 | this.logInfo('# No relations to display'.yellow); 232 | } 233 | } 234 | 235 | /** 236 | * Display an error message to the user 237 | * 238 | * @param {string} msg the message to display 239 | * @param {string} additional any fyi description about the error 240 | */ 241 | err(msg, additional) { 242 | if (this.logLevel <= LOG_LEVELS.SILENT) { 243 | return; 244 | } 245 | console.error(msg.red); 246 | this.hasErrors = true; 247 | if (additional != undefined) { 248 | console.error(JSON.stringify(additional).yellow); 249 | } 250 | } 251 | 252 | /** 253 | * Display an unrecoverable error message to the user 254 | * 255 | * @param {string} msg the message to display 256 | * @param {string} additional any fyi description about the error 257 | */ 258 | fatal(msg, additional) { 259 | this.err(msg, additional); 260 | process.exit(1); 261 | } 262 | 263 | /** 264 | * Display an info log message to the user 265 | * 266 | * @param {string} msg the message to display 267 | */ 268 | logInfo(msg) { 269 | if (this.logLevel <= LOG_LEVELS.SILENT) { 270 | return; 271 | } 272 | 273 | if (msg == undefined || msg == '') { 274 | console.log(); 275 | } else { 276 | console.log(msg); 277 | } 278 | } 279 | 280 | 281 | /** 282 | * Display an debug log message to the user 283 | * 284 | * @param {string} msg the message to display 285 | */ 286 | logDebug(msg) { 287 | if (this.logLevel < LOG_LEVELS.DEBUG) { 288 | return; 289 | } 290 | this.logInfo(msg); 291 | } 292 | } 293 | 294 | /** 295 | * A representation of the assetlinks.json file 296 | * 297 | * Provides methods for interacting with the file 298 | * 299 | * @type {AssetFile} 300 | */ 301 | class AssetFile { 302 | 303 | /** 304 | * Build the assetfile representation 305 | * @param {string} filename the full path to the file (hosted or local) 306 | * @return {AssetFile} the configured AssetFile 307 | */ 308 | constructor(filename) { 309 | if (filename.length < 1) { 310 | throw new Error('Empty filename'); 311 | } 312 | this.filename = this._cleanRemoteFilename(filename); 313 | this.uri = url.parse(this.filename); 314 | } 315 | 316 | /** 317 | * Get the unfiltered filename 318 | 319 | * @return {string} the filename 320 | */ 321 | getFilename() { 322 | return this.filename; 323 | } 324 | 325 | /** 326 | * Check if the file path is local 327 | * 328 | * @return {Boolean} true if the file has a local path 329 | */ 330 | isLocal() { 331 | return !(this.uri.hostname); 332 | } 333 | 334 | /** 335 | * Get the contents of the file from a URL 336 | * 337 | * Retreieves a handler to the file from calling a https request. The handler 338 | * function should accept a single http.IncomingMessage object. 339 | * 340 | * @param {Function} handler the handling function for the response 341 | * @param {string} userAgent the useragent to supply when requesting the file 342 | */ 343 | getRemote(handler, userAgent) { 344 | userAgent = (userAgent == undefined) ? DEFAULT_USER_AGENT : userAgent; 345 | https.get( 346 | { 347 | hostname: this.uri.hostname, 348 | path: this.uri.path, 349 | headers: {'User-Agent': userAgent} 350 | }, 351 | handler); 352 | } 353 | 354 | /** 355 | * Get the contents of a file if it's hosted on the local system. 356 | * 357 | * The handler function should expect the contents of the file 358 | * 359 | * @param {Function} success to handle the contents of the file 360 | * @param {Function} fail to handle errors with retrieving the file 361 | */ 362 | getLocal(success, fail) { 363 | var err = ''; 364 | try { 365 | let contents = fs.readFileSync(this.filename); 366 | if (contents.length > 0) { 367 | return success(contents); 368 | } 369 | err = 'No file contents'; 370 | } catch (e) { 371 | err = e; 372 | } 373 | fail(err); 374 | } 375 | 376 | /** 377 | * Clean the Remote Filename by checking common locations and ensuring to it's on HTTPS. 378 | * 379 | * @param {String} the filename to clean 380 | * @return {String} the cleaned filename 381 | */ 382 | _cleanRemoteFilename(filename) { 383 | var uri = url.parse(filename); 384 | if (!uri.hostname) { 385 | return filename; 386 | } 387 | if (uri.protocol != "https") { 388 | if (uri.protocol && uri.protocol.length > 0) { 389 | filename = filename.substr(uri.protocol.length + 2); 390 | } 391 | filename = "https://" + filename; 392 | uri = url.parse(filename); 393 | } 394 | if (uri.path.length < 2) { 395 | 396 | filename += (filename.substr(filename.length - 1) != '/') ? '/' : ''; 397 | filename += ".well-known/assetlinks.json"; 398 | } 399 | return filename; 400 | } 401 | } 402 | 403 | /** 404 | * A representation of the top level entry in an assetlinks.json file 405 | * 406 | * @type {AssetEntry} 407 | */ 408 | class AssetEntry { 409 | 410 | /** 411 | * Construct an AssetEntry 412 | * 413 | * @param {Object} entry the object repr of the assetlinks.json file 414 | * @return {AssetEntry} the configured AssetEntry 415 | */ 416 | constructor(entry) { 417 | this.data = entry; 418 | if (!AssetEntry.isEntry(entry)) { 419 | throw Error('Item is not an AssetEntry'); 420 | } 421 | this.relation = new AssetRelation(this.data['relation']); 422 | this.target = new AssetTarget(this.data['target']); 423 | } 424 | 425 | /** 426 | * Get the relation of the entry 427 | * @return {AssetRelation} the relation property 428 | */ 429 | getRelation() { 430 | return this.relation; 431 | } 432 | 433 | /** 434 | * Get the target of the entry 435 | * @return {AssetTarget} the target property 436 | */ 437 | getTarget() { 438 | return this.target; 439 | } 440 | 441 | /** 442 | * Check if a entry is an instance of an AssetEntry 443 | * @param {Object} data the entry to test 444 | * @return {Boolean} true if it's compatible 445 | */ 446 | static isEntry(data) { 447 | if (('relation' in data) && ('target' in data)) { 448 | return true; 449 | } 450 | return false; 451 | } 452 | } 453 | 454 | /** 455 | * A representation of the top level entry that is an include statement. 456 | * @type {IncludeEntry} 457 | */ 458 | class IncludeEntry { 459 | 460 | /** 461 | * [constructor description] 462 | * @param {Object} entry the data to represent 463 | * @return {IncludeEntry} the prepared IncludeEntry 464 | */ 465 | constructor(entry) { 466 | this.data = entry; 467 | if (!IncludeEntry.isEntry(entry)) { 468 | throw Error('No include statement defined'); 469 | } 470 | this.include = this.data['include']; 471 | } 472 | 473 | /** 474 | * Get the include value of the entry 475 | * @return string the include value 476 | */ 477 | getInclude() { 478 | return this.include; 479 | } 480 | 481 | /** 482 | * Check if a entry is an instance of an IncludeEntry 483 | * @param {Object} data the entry to test 484 | * @return {Boolean} true if it's compatible 485 | */ 486 | static isEntry(data) { 487 | return ('include' in data); 488 | } 489 | } 490 | 491 | /** 492 | * The representation of the relation property of a assetlinks entry 493 | * @type {AssetRelation} 494 | */ 495 | class AssetRelation { 496 | 497 | /** 498 | * Construct an AssetRelation object 499 | * @param {Object} data the data that represents the AssetRelation 500 | * @return {AssetRelation} the configured AssetRelation 501 | */ 502 | constructor(data) { 503 | if (data.length < 1) { 504 | throw Error('No relation data'); 505 | } 506 | this.data = data; 507 | } 508 | 509 | /** 510 | * Test if the relation has a login credentials association 511 | * @return {Boolean} true if there's a login credentials association 512 | */ 513 | hasGetLoginCreds() { 514 | if (this.data.indexOf('delegate_permission/common.get_login_creds') >= 0) { 515 | return true; 516 | } 517 | return false; 518 | } 519 | 520 | /** 521 | * Test if the relation has a handle all urls association 522 | * @return {Boolean} true if there's a handle all urls association 523 | */ 524 | hasHandleAllUrls() { 525 | if (this.data.indexOf('delegate_permission/common.handle_all_urls') >= 0) { 526 | return true; 527 | } 528 | return false; 529 | } 530 | } 531 | 532 | /** 533 | * A representation of the Target property of an assetlinks entry. 534 | * @type {AssetTarget} 535 | */ 536 | class AssetTarget { 537 | 538 | /** 539 | * Construct an AssetTarget object 540 | * @param {Object} data the data that represents the AssetTarget 541 | * @return {AssetRelation} the configured AssetTarget 542 | */ 543 | constructor(data) { 544 | if (!('namespace' in data)) { 545 | throw Error('Missing namespace in target'); 546 | } 547 | this.data = data; 548 | } 549 | 550 | /** 551 | * Get the raw namespace of the target 552 | * @return {[type]} [description] 553 | */ 554 | getNamespace() { 555 | return this.data['namespace']; 556 | } 557 | 558 | /** 559 | * Test if the target is the web 560 | * @return {Boolean} true if the target is web 561 | */ 562 | isWeb() { 563 | if (this.getNamespace() == 'web' && this.getSite()) { 564 | return true; 565 | } 566 | return false; 567 | } 568 | 569 | /** 570 | * Test if the target is an android app 571 | * @return {Boolean} true if the target is android 572 | */ 573 | isAndroid() { 574 | if (this.getNamespace() == 'android_app' && this.getAndroidData()) { 575 | return true; 576 | } 577 | return false; 578 | } 579 | 580 | /** 581 | * Get the site information for site targets 582 | * @return {string} the site information 583 | */ 584 | getSite() { 585 | if (!('site' in this.data)) { 586 | throw Error('Missing site from target'); 587 | } 588 | return this.data['site']; 589 | } 590 | 591 | /** 592 | * Get the android app information for the target 593 | * 594 | * The response is a dictionary mapping { 595 | * sha256_cert_fingerprints: , 596 | * package_name: 597 | * } 598 | * 599 | * @return {Object} the android app information 600 | */ 601 | getAndroidData() { 602 | if (!('sha256_cert_fingerprints' in this.data)) { 603 | throw Error('Missing android fingerprint from target'); 604 | } 605 | if (!('package_name' in this.data)) { 606 | throw Error('Missing android package name from target'); 607 | } 608 | return { 609 | sha256_cert_fingerprints: this.data['sha256_cert_fingerprints'], 610 | package_name: this.data['package_name'] 611 | } 612 | } 613 | } 614 | 615 | module.exports.AssetCheck = AssetCheck; 616 | module.exports.AssetFile = AssetFile; 617 | module.exports.LOG_SILENT = LOG_LEVELS.SILENT; 618 | module.exports.LOG_INFO = LOG_LEVELS.INFO; 619 | module.exports.LOG_DEBUG = LOG_LEVELS.DEBUG; 620 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "asset-check", 3 | "version": "0.1.0", 4 | "description": "Tool for checking your assetlinks.json", 5 | "main": "index.js", 6 | "bin": { 7 | "asset-check": "./run.js" 8 | }, 9 | "scripts": { 10 | "test": "mocha" 11 | }, 12 | "author": "Paul Matthews", 13 | "license": "SEE LICENSE IN LICENSE.txt", 14 | "dependencies": { 15 | "colors": "^1.1.2", 16 | "commander": "^2.9.0", 17 | "jsonschema": "^1.1.1", 18 | "urlencode": "^1.1.0" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git://github.com/google/asset-check.git" 23 | }, 24 | "devDependencies": { 25 | "chai": "^3.5.0", 26 | "mocha": "^3.4.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | * Copyright 2017 Google Inc. All rights reserved. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 'use strict'; 18 | 19 | const program = require('commander'); 20 | const ac = require('./index.js') 21 | const AssetCheck = ac.AssetCheck; 22 | const LOG_INFO = ac.LOG_INFO; 23 | const LOG_DEBUG = ac.LOG_DEBUG; 24 | 25 | program 26 | .version('0.0.1') 27 | .description('Check your assetlinks.json file') 28 | .option('-d, --debug', 'Enable debug') 29 | .option('-u, --user-agent ', 'Specify user agent') 30 | .parse(process.argv); 31 | 32 | if (!program.args.length) { 33 | console.error("No filename specified"); 34 | return; 35 | } 36 | 37 | let logLevel = (program.debug) ? LOG_DEBUG : LOG_INFO; 38 | let assetCheck = new AssetCheck(program.args.shift(), logLevel, 39 | program.userAgent); 40 | assetCheck.run(); 41 | -------------------------------------------------------------------------------- /schema.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 'use strict'; 17 | 18 | /* JSON Schema Base */ 19 | const SCHEMA_BASE = { 20 | "id": "/Base", 21 | "oneOf": [ 22 | { 23 | "type": "array", 24 | "minItems": 1, 25 | "items": { "$ref": "/BaseEntity"}, 26 | "title": "Relationship Entity", 27 | }, 28 | { 29 | "type": "array", 30 | "minItems": 1, 31 | "maxItems": 1, 32 | "items": { "$ref": "/IncludeEntity"}, 33 | "title": "Include Entity", 34 | } 35 | ], 36 | }; 37 | 38 | /* JSON Schema Base Entity */ 39 | const SCHEMA_ENTITY = { 40 | "id": "/BaseEntity", 41 | "type": "object", 42 | "properties": { 43 | "relation": { 44 | "$ref": "/RelationEntity", 45 | "required": true 46 | }, 47 | "target": { 48 | "oneOf": [ 49 | { "$ref": "/WebTarget"}, 50 | { "$ref": "/AndroidTarget"} 51 | ], 52 | "required": true 53 | } 54 | } 55 | } 56 | 57 | const SCHEMA_INCLUDE = { 58 | "id": "/IncludeEntity", 59 | "type": "object", 60 | "properties": { 61 | "include": { 62 | "type": "string", 63 | "required": true 64 | }, 65 | } 66 | } 67 | 68 | /* JSON Schema Relation Entity */ 69 | const SCHEMA_RELATION = { 70 | "id": "/RelationEntity", 71 | "type": "array", 72 | "items": {"type": "string"} 73 | }; 74 | 75 | /* JSON Schema Web Target Entity */ 76 | const SCHEMA_WEB = { 77 | "id": "/WebTarget", 78 | "type": "object", 79 | "properties": { 80 | "namespace": { 81 | "type": "string", 82 | "enum": ["web"], 83 | "required": true 84 | }, 85 | "site": { 86 | "type": "string", 87 | "required": true 88 | } 89 | }, 90 | "additionalProperties": false 91 | } 92 | 93 | /* JSON Schema Android Target Entity */ 94 | const SCHEMA_ANDROID = { 95 | "id": "/AndroidTarget", 96 | "type": "object", 97 | "properties": { 98 | "namespace": { 99 | "type": "string", 100 | "enum": ["android_app"], 101 | "required": true 102 | }, 103 | "sha256_cert_fingerprints": { 104 | "type": "array", 105 | "items": {"type": "string"}, 106 | "required": true 107 | }, 108 | "package_name": { 109 | "type": "string", 110 | "required": true 111 | }, 112 | }, 113 | "additionalProperties": false 114 | } 115 | 116 | module.exports.assetlinksSchema = { 117 | "/Base": SCHEMA_BASE, // The first entry has to be the base schema 118 | "/BaseEntity": SCHEMA_ENTITY, 119 | "/IncludeEntity": SCHEMA_INCLUDE, 120 | "/RelationEntity": SCHEMA_RELATION, 121 | "/WebTarget": SCHEMA_WEB, 122 | "/AndroidTarget": SCHEMA_ANDROID 123 | }; 124 | -------------------------------------------------------------------------------- /test/data/include-pass.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "include": "https://example.com/includedstatements.json" 3 | }] 4 | -------------------------------------------------------------------------------- /test/data/include-target-fail.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "include": "https://example.com/includedstatements.json" 3 | }, 4 | { 5 | "relation": ["delegate_permission/common.handle_all_urls"], 6 | "target": { 7 | "namespace": "android_app", 8 | "package_name": "com.example", 9 | "sha256_cert_fingerprints": 10 | ["A0:AA:0A:0A:00:0A:00:AA:00:A0:A0:00:00:A0:00:0A:AA:00:A0:AA:00:00:AA:00:00:00:AA:0A:0A:00:AA:00"] 11 | } 12 | }] 13 | -------------------------------------------------------------------------------- /test/data/invalid-byte-order-mark.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "relation": [ 4 | "delegate_permission/common.get_login_creds", 5 | "delegate_permission/common.handle_all_urls" 6 | ], 7 | "target": { 8 | "namespace": "web", 9 | "site": "https://example.com" 10 | } 11 | }, 12 | { 13 | "relation": [ 14 | "delegate_permission/common.handle_all_urls" 15 | ], 16 | "target": { 17 | "namespace": "android_app", 18 | "package_name": "com.example", 19 | "sha256_cert_fingerprints": [ 20 | "A0:AA:0A:0A:00:0A:00:AA:00:A0:A0:00:00:A0:00:0A:AA:00:A0:AA:00:00:AA:00:00:00:AA:0A:0A:00:AA:00" 21 | ] 22 | } 23 | } 24 | ] -------------------------------------------------------------------------------- /test/data/missing-target-namespace-fail.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "relation": [], 3 | "target": { 4 | "package_name": "com.example", 5 | "sha256_cert_fingerprints": 6 | ["A0:AA:0A:0A:00:0A:00:AA:00:A0:A0:00:00:A0:00:0A:AA:00:A0:AA:00:00:AA:00:00:00:AA:0A:0A:00:AA:00"] 7 | } 8 | }] 9 | -------------------------------------------------------------------------------- /test/data/multiple-include-fail.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "include": "https://example.com/includedstatements.json" 3 | }, 4 | { 5 | "include": "https://example.com/includedstatements2.json" 6 | }] 7 | -------------------------------------------------------------------------------- /test/data/simple-fail.json: -------------------------------------------------------------------------------- 1 | [{ 2 | }] 3 | -------------------------------------------------------------------------------- /test/data/simple-pass.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "relation": [ 3 | "delegate_permission/common.get_login_creds","delegate_permission/common.handle_all_urls" 4 | ], 5 | "target": { 6 | "namespace": "web", 7 | "site": "https://example.com" 8 | } 9 | }, 10 | { 11 | "relation": ["delegate_permission/common.handle_all_urls"], 12 | "target": { 13 | "namespace": "android_app", 14 | "package_name": "com.example", 15 | "sha256_cert_fingerprints": 16 | ["A0:AA:0A:0A:00:0A:00:AA:00:A0:A0:00:00:A0:00:0A:AA:00:A0:AA:00:00:AA:00:00:00:AA:0A:0A:00:AA:00"] 17 | } 18 | }] 19 | -------------------------------------------------------------------------------- /test/data/unkown-target-namespace-fail.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "relation": [], 3 | "target": { 4 | "namespace": "foo", 5 | "package_name": "com.example", 6 | "sha256_cert_fingerprints": 7 | ["A0:AA:0A:0A:00:0A:00:AA:00:A0:A0:00:00:A0:00:0A:AA:00:A0:AA:00:00:AA:00:00:00:AA:0A:0A:00:AA:00"] 8 | } 9 | }] 10 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2017 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 'use strict'; 17 | const should = require('chai').should(); 18 | const fs = require('fs'); 19 | const ac = require('../index.js'); 20 | const AssetCheck = ac.AssetCheck; 21 | const AssetFile = ac.AssetFile; 22 | const LOG_LEVEL = ac.LOG_SILENT; 23 | 24 | var getTestFile = function(filename) { 25 | return fs.readFileSync(process.cwd() + '/test/data/' + filename); 26 | } 27 | 28 | describe('#AssetCheck', function() { 29 | var asset = new AssetCheck('foo', LOG_LEVEL); 30 | 31 | it('parses simple json', function() { 32 | asset.handleParseJson( 33 | getTestFile('simple-pass.json'), 34 | (data) => { 35 | data.should.not.be.null; 36 | }, 37 | (err) => { 38 | throw new Error('Should not have failed.'); 39 | }); 40 | }) 41 | 42 | it('accepts include statements', function() { 43 | asset.handleParseJson( 44 | getTestFile('include-pass.json'), 45 | (data) => { 46 | data.should.not.be.null; 47 | }, 48 | (err) => { 49 | throw new Error('Should not have failed.'); 50 | }); 51 | }) 52 | 53 | it('rejects invalid json', function() { 54 | asset.handleParseJson( 55 | getTestFile('simple-fail.json'), 56 | (data) => { 57 | throw new Error('Should not have passed.'); 58 | }, 59 | (err) => { 60 | err.should.not.be.null; 61 | }); 62 | }) 63 | 64 | xit('rejects json with a byte order mark', function() { 65 | asset.handleParseJson( 66 | getTestFile('invalid-byte-order-mark.json'), 67 | (data) => { 68 | throw new Error('Should not have passed.'); 69 | }, 70 | (err) => { 71 | err.should.equal( 72 | 'File must be UTF-8 encoded _without_ a byte order mark (BOM)'); 73 | }); 74 | }); 75 | 76 | it('rejects missing namespace from target', function() { 77 | asset.handleParseJson( 78 | getTestFile('missing-target-namespace-fail.json'), 79 | (data) => { 80 | throw new Error('Should not have passed.'); 81 | }, 82 | (err) => { 83 | err.should.not.be.null; 84 | }); 85 | }) 86 | 87 | it('rejects unkown namespace from target', function() { 88 | asset.handleParseJson( 89 | getTestFile('unkown-target-namespace-fail.json'), 90 | (data) => { 91 | throw new Error('Should not have passed.'); 92 | }, 93 | (err) => { 94 | err.should.not.be.null; 95 | }); 96 | }) 97 | 98 | it('rejects multiple include statements', function() { 99 | asset.handleParseJson( 100 | getTestFile('multiple-include-fail.json'), 101 | (data) => { 102 | throw new Error('Should not have passed.'); 103 | }, 104 | (err) => { 105 | err.should.not.be.null; 106 | }); 107 | }) 108 | 109 | it('rejects mixing include and target statements', function() { 110 | asset.handleParseJson( 111 | getTestFile('include-target-fail.json'), 112 | (data) => { 113 | throw new Error('Should not have passed.'); 114 | }, 115 | (err) => { 116 | err.should.not.be.null; 117 | }); 118 | }) 119 | }); 120 | 121 | describe('#AssetFile', function() { 122 | it('allows local filenames', function() { 123 | var testData = ['./local-file.txt', 'assetlinks.json', 124 | "../../path/to/local/file.json", 125 | "../https/www.foo.com/.well-known/assetlinks.json"]; 126 | 127 | for (var i in testData) { 128 | var testAssetFile = new AssetFile(testData[i]); 129 | testAssetFile.getFilename().should.equal(testData[i]); 130 | } 131 | }); 132 | 133 | it('enforces https protocol', function() { 134 | var testData = ['http://www.example.com/', 'http://www.google.com', 135 | "http://foo.bar"]; 136 | for (var i in testData) { 137 | var testAssetFile = new AssetFile(testData[i]); 138 | testAssetFile.getFilename().should.contain("https://"); 139 | testAssetFile.getFilename().should.not.contain("http://"); 140 | } 141 | }); 142 | 143 | it("should rewrite missing paths", function() { 144 | var testData = { 145 | 'https://www.example.com/': 'https://www.example.com/.well-known/assetlinks.json', 146 | 'https://www.google.com': 'https://www.google.com/.well-known/assetlinks.json', 147 | 'https://foo.bar': 'https://foo.bar/.well-known/assetlinks.json'}; 148 | for (var filename in testData) { 149 | var testAssetFile = new AssetFile(filename); 150 | testAssetFile.getFilename().should.equal(testData[filename]); 151 | } 152 | }); 153 | 154 | 155 | it("doesn't rewrite absolute paths", function() { 156 | var testData = ['https://www.example.com/asset-links.json', 157 | 'https://www.google.com/path/to/specific/file.json', 158 | "https://foo.bar/file.txt"]; 159 | 160 | for (var i in testData) { 161 | var testAssetFile = new AssetFile(testData[i]); 162 | testAssetFile.getFilename().should.equal(testData[i]); 163 | } 164 | }); 165 | }); --------------------------------------------------------------------------------