diff --git a/tests/domains.test.js b/tests/domains.test.js index 90262cdef..f1e4954a3 100644 --- a/tests/domains.test.js +++ b/tests/domains.test.js @@ -3,9 +3,7 @@ const fs = require("fs-extra"); const path = require("path"); const domainsPath = path.resolve("domains"); -const files = fs - .readdirSync(domainsPath) - .filter((file) => file.endsWith(".json")); +const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); const domainCache = {}; @@ -15,15 +13,11 @@ function getDomainData(subdomain) { } try { - const data = fs.readJsonSync( - path.join(domainsPath, `${subdomain}.json`), - ); + const data = fs.readJsonSync(path.join(domainsPath, `${subdomain}.json`)); domainCache[subdomain] = data; // Cache the domain data return data; } catch (error) { - throw new Error( - `Failed to read JSON for ${subdomain}: ${error.message}`, - ); + throw new Error(`Failed to read JSON for ${subdomain}: ${error.message}`); } } @@ -52,30 +46,24 @@ t("Nested subdomains should not exist without a parent subdomain", (t) => { const parentSubdomain = getParentSubdomain(subdomain); t.true( parentSubdomain && files.includes(`${parentSubdomain}.json`), - `${file}: Parent subdomain does not exist`, + `${file}: Parent subdomain does not exist` ); } }); }); -t( - "Nested subdomains should not exist if the parent subdomain has NS records", - (t) => { - files.forEach((file) => { - const subdomain = file.replace(/\.json$/, ""); +t("Nested subdomains should not exist if the parent subdomain has NS records", (t) => { + files.forEach((file) => { + const subdomain = file.replace(/\.json$/, ""); - if (subdomain.split(".").length > 1) { - const parentSubdomain = getParentSubdomain(subdomain); - const parentDomain = getDomainData(parentSubdomain); + if (subdomain.split(".").length > 1) { + const parentSubdomain = getParentSubdomain(subdomain); + const parentDomain = getDomainData(parentSubdomain); - t.true( - !parentDomain.record.NS, - `${file}: Parent subdomain has NS records`, - ); - } - }); - }, -); + t.true(!parentDomain.record.NS, `${file}: Parent subdomain has NS records`); + } + }); +}); t("Nested subdomains should be owned by the parent subdomain's owner", (t) => { files.forEach((file) => { @@ -87,9 +75,8 @@ t("Nested subdomains should be owned by the parent subdomain's owner", (t) => { const parentDomain = getDomainData(parentSubdomain); t.true( - data.owner.username.toLowerCase() === - parentDomain.owner.username.toLowerCase(), - `${file}: Owner does not match the parent subdomain`, + data.owner.username.toLowerCase() === parentDomain.owner.username.toLowerCase(), + `${file}: Owner does not match the parent subdomain` ); } }); @@ -103,31 +90,15 @@ t("Subdomains containing an underscore can only have specific records", (t) => { const data = getDomainData(subdomain); const recordKeys = Object.keys(data.record); - if ( - subdomain.startsWith("_acme-challenge.") || - subdomain.includes("._domainkey.") - ) { + if (subdomain.startsWith("_acme-challenge.") || subdomain.includes("._domainkey.")) { t.true( - recordKeys.every((key) => - new Set(["TXT", "CNAME"]).has(key), - ), - `${file}: This type of subdomain can only have TXT or CNAME records`, - ); - } else if ( - subdomain.includes("._tcp.") || - subdomain.includes("._udp.") - ) { - t.deepEqual( - recordKeys, - ["SRV"], - `${file}: This type of subdomain can only have SRV records`, + recordKeys.every((key) => new Set(["TXT", "CNAME"]).has(key)), + `${file}: This type of subdomain can only have TXT or CNAME records` ); + } else if (subdomain.includes("._tcp.") || subdomain.includes("._udp.")) { + t.deepEqual(recordKeys, ["SRV"], `${file}: This type of subdomain can only have SRV records`); } else { - t.deepEqual( - recordKeys, - ["TXT"], - `${file}: Subdomains with underscores can only have TXT records`, - ); + t.deepEqual(recordKeys, ["TXT"], `${file}: Subdomains with underscores can only have TXT records`); } } }); diff --git a/tests/pr.test.js b/tests/pr.test.js new file mode 100644 index 000000000..f2cf47c2d --- /dev/null +++ b/tests/pr.test.js @@ -0,0 +1,47 @@ +const t = require("ava"); +const fs = require("fs-extra"); +const path = require("path"); + +const domainsPath = path.resolve("domains"); +const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); + +const changedFiles = JSON.parse(process.env.CHANGED_FILES); +const prAuthor = process.env.PR_AUTHOR.toLowerCase(); +const prLabels = JSON.parse(process.env.PR_LABELS); +const trustedUsers = require("../util/trusted.json").map((u) => u.toLowerCase()); + +function getDomainData(subdomain) { + try { + const data = fs.readJsonSync(path.join(domainsPath, `${subdomain}.json`)); + return data; + } catch (error) { + throw new Error(`Failed to read JSON for ${subdomain}: ${error.message}`); + } +} + +t("Users can only update their own subdomains", (t) => { + if (process.env.PR_AUTHOR && process.env.CHANGED_FILES) { + const changedJSONFiles = changedFiles + .filter((file) => file.startsWith("domains/")) + .map((file) => path.basename(file)) + .forEach((file) => file.replace(/\.json$/, "")); + + if (trustedUsers.includes(prAuthor) || prLabels.includes("bypass-owner-check")) { + t.pass(); + } else { + files + .filter((file) => changedJSONFiles.includes(file)) + .forEach((file) => { + const subdomain = file.replace(/\.json$/, ""); + const data = getDomainData(subdomain); + + t.true( + data.owner.username.toLowerCase() === prAuthor, + `${subdomain}: ${prAuthor} does not own ${subdomain}.is-a.dev` + ); + }); + } + } + + t.pass(); +}); diff --git a/tests/proxy.test.js b/tests/proxy.test.js index 0f1c65039..d7c73bc5b 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -25,29 +25,22 @@ function validateProxiedRecords(t, data, file) { const recordTypes = Array.from(requiredRecordsToProxy).join(", "); if (data.proxied) { - const hasProxiedRecord = Object.keys(data.record).some((key) => - requiredRecordsToProxy.has(key), - ); + const hasProxiedRecord = Object.keys(data.record).some((key) => requiredRecordsToProxy.has(key)); t.true( hasProxiedRecord, - `${file}: Proxied is true but there are no records that can be proxied (${recordTypes} expected)`, + `${file}: Proxied is true but there are no records that can be proxied (${recordTypes} expected)` ); } } const domainsPath = path.resolve("domains"); -const files = fs - .readdirSync(domainsPath) - .filter((file) => file.endsWith(".json")); +const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); -t( - "Domains with proxy enabled must have at least one proxy-able record", - (t) => { - files.forEach((file) => { - const domain = getDomainData(file); +t("Domains with proxy enabled must have at least one proxy-able record", (t) => { + files.forEach((file) => { + const domain = getDomainData(file); - validateProxiedRecords(t, domain, file); - }); - }, -); + validateProxiedRecords(t, domain, file); + }); +}); diff --git a/tests/records.test.js b/tests/records.test.js index 9f05a650b..fbbeee156 100644 --- a/tests/records.test.js +++ b/tests/records.test.js @@ -2,29 +2,14 @@ const t = require("ava"); const fs = require("fs-extra"); const path = require("path"); -const validRecordTypes = new Set([ - "A", - "AAAA", - "CAA", - "CNAME", - "DS", - "MX", - "NS", - "SRV", - "TXT", - "URL", -]); -const hostnameRegex = - /^(?=.{1,253}$)(?:(?:[_a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+[a-zA-Z]{2,63}$/; -const ipv4Regex = - /^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$/; +const validRecordTypes = new Set(["A", "AAAA", "CAA", "CNAME", "DS", "MX", "NS", "SRV", "TXT", "URL"]); +const hostnameRegex = /^(?=.{1,253}$)(?:(?:[_a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\.)+[a-zA-Z]{2,63}$/; +const ipv4Regex = /^(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$/; const ipv6Regex = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:){1,7}:$|^(?:[0-9a-fA-F]{1,4}:){0,6}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$/; const domainsPath = path.resolve("domains"); -const files = fs - .readdirSync(domainsPath) - .filter((file) => file.endsWith(".json")); +const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); const domainCache = {}; @@ -53,7 +38,7 @@ function expandIPv6(ip) { segments = [ ...nonEmptySegments.slice(0, emptyIndex), ...Array(missingSegments).fill("0000"), - ...nonEmptySegments.slice(emptyIndex), + ...nonEmptySegments.slice(emptyIndex) ]; } @@ -63,11 +48,7 @@ function expandIPv6(ip) { function validateIPv4(ip, proxied) { const parts = ip.split(".").map(Number); - if ( - parts.length !== 4 || - parts.some((part) => isNaN(part) || part < 0 || part > 255) - ) - return false; + if (parts.length !== 4 || parts.some((part) => isNaN(part) || part < 0 || part > 255)) return false; if (ip === "192.0.2.1" && proxied) return true; return !( @@ -113,153 +94,100 @@ function validateRecordValues(t, data, file) { Object.entries(data.record).forEach(([key, value]) => { // General validation for arrays if (["A", "AAAA", "MX", "NS"].includes(key)) { - t.true( - Array.isArray(value), - `${file}: Record value for ${key} should be an array`, - ); + t.true(Array.isArray(value), `${file}: Record value for ${key} should be an array`); value.forEach((record, idx) => { t.true( typeof record === "string", - `${file}: Record value for ${key} should be a string at index ${idx}`, + `${file}: Record value for ${key} should be a string at index ${idx}` ); if (key === "A") { - t.true( - ipv4Regex.test(record), - `${file}: Invalid IPv4 address for ${key} at index ${idx}`, - ); + t.true(ipv4Regex.test(record), `${file}: Invalid IPv4 address for ${key} at index ${idx}`); t.true( validateIPv4(record, data.proxied), - `${file}: Invalid IPv4 address for ${key} at index ${idx}`, + `${file}: Invalid IPv4 address for ${key} at index ${idx}` ); } else if (key === "AAAA") { const expandedIPv6 = expandIPv6(record); - t.true( - ipv6Regex.test(expandedIPv6), - `${file}: Invalid IPv6 address for ${key} at index ${idx}`, - ); - t.true( - validateIPv6(expandedIPv6), - `${file}: Invalid IPv6 address for ${key} at index ${idx}`, - ); + t.true(ipv6Regex.test(expandedIPv6), `${file}: Invalid IPv6 address for ${key} at index ${idx}`); + t.true(validateIPv6(expandedIPv6), `${file}: Invalid IPv6 address for ${key} at index ${idx}`); } else if (["MX", "NS"].includes(key)) { - t.true( - isValidHostname(record), - `${file}: Invalid hostname for ${key} at index ${idx}`, - ); + t.true(isValidHostname(record), `${file}: Invalid hostname for ${key} at index ${idx}`); } }); } // CNAME and URL validations if (["CNAME", "URL"].includes(key)) { - t.true( - typeof value === "string", - `${file}: Record value for ${key} should be a string`, - ); + t.true(typeof value === "string", `${file}: Record value for ${key} should be a string`); if (key === "CNAME") { - t.true( - isValidHostname(value), - `${file}: Invalid hostname for ${key}`, - ); + t.true(isValidHostname(value), `${file}: Invalid hostname for ${key}`); t.true(value !== file, `${file}: CNAME cannot point to itself`); } else if (key === "URL") { t.true( value.startsWith("http://") || value.startsWith("https://"), - `${file}: Record value for ${key} must start with http:// or https://`, - ); - t.notThrows( - () => new URL(value), - `${file}: Invalid URL for ${key}`, + `${file}: Record value for ${key} must start with http:// or https://` ); + t.notThrows(() => new URL(value), `${file}: Invalid URL for ${key}`); const urlHost = new URL(value).host; const isSelfReferencing = - file === "@.json" - ? urlHost === "is-a.dev" - : urlHost === `${subdomain}.is-a.dev`; + file === "@.json" ? urlHost === "is-a.dev" : urlHost === `${subdomain}.is-a.dev`; - t.false( - isSelfReferencing, - `${file}: URL cannot point to itself`, - ); + t.false(isSelfReferencing, `${file}: URL cannot point to itself`); } } // CAA, DS, SRV validations if (["CAA", "DS", "SRV"].includes(key)) { - t.true( - Array.isArray(value), - `${file}: Record value for ${key} should be an array`, - ); + t.true(Array.isArray(value), `${file}: Record value for ${key} should be an array`); value.forEach((record, idx) => { t.true( typeof record === "object", - `${file}: Record value for ${key} should be an object at index ${idx}`, + `${file}: Record value for ${key} should be an object at index ${idx}` ); if (key === "CAA") { t.true( ["issue", "issuewild", "iodef"].includes(record.tag), - `${file}: Invalid tag for CAA at index ${idx}`, - ); - t.true( - typeof record.value === "string", - `${file}: Invalid value for CAA at index ${idx}`, + `${file}: Invalid tag for CAA at index ${idx}` ); + t.true(typeof record.value === "string", `${file}: Invalid value for CAA at index ${idx}`); t.true( isValidHostname(record.value) || record.value === ";", - `${file}: Value must be a hostname or semicolon for CAA at index ${idx}`, + `${file}: Value must be a hostname or semicolon for CAA at index ${idx}` ); } else if (key === "DS") { t.true( - Number.isInteger(record.key_tag) && - record.key_tag >= 0 && - record.key_tag <= 65535, - `${file}: Invalid key_tag for DS at index ${idx}`, + Number.isInteger(record.key_tag) && record.key_tag >= 0 && record.key_tag <= 65535, + `${file}: Invalid key_tag for DS at index ${idx}` ); t.true( - Number.isInteger(record.algorithm) && - record.algorithm >= 0 && - record.algorithm <= 255, - `${file}: Invalid algorithm for DS at index ${idx}`, + Number.isInteger(record.algorithm) && record.algorithm >= 0 && record.algorithm <= 255, + `${file}: Invalid algorithm for DS at index ${idx}` ); t.true( - Number.isInteger(record.digest_type) && - record.digest_type >= 0 && - record.digest_type <= 255, - `${file}: Invalid digest_type for DS at index ${idx}`, - ); - t.true( - isValidHexadecimal(record.digest), - `${file}: Invalid digest for DS at index ${idx}`, + Number.isInteger(record.digest_type) && record.digest_type >= 0 && record.digest_type <= 255, + `${file}: Invalid digest_type for DS at index ${idx}` ); + t.true(isValidHexadecimal(record.digest), `${file}: Invalid digest for DS at index ${idx}`); } else if (key === "SRV") { t.true( - Number.isInteger(record.priority) && - record.priority >= 0 && - record.priority <= 65535, - `${file}: Invalid priority for SRV at index ${idx}`, + Number.isInteger(record.priority) && record.priority >= 0 && record.priority <= 65535, + `${file}: Invalid priority for SRV at index ${idx}` ); t.true( - Number.isInteger(record.weight) && - record.weight >= 0 && - record.weight <= 65535, - `${file}: Invalid weight for SRV at index ${idx}`, + Number.isInteger(record.weight) && record.weight >= 0 && record.weight <= 65535, + `${file}: Invalid weight for SRV at index ${idx}` ); t.true( - Number.isInteger(record.port) && - record.port >= 0 && - record.port <= 65535, - `${file}: Invalid port for SRV at index ${idx}`, - ); - t.true( - isValidHostname(record.target), - `${file}: Invalid target for SRV at index ${idx}`, + Number.isInteger(record.port) && record.port >= 0 && record.port <= 65535, + `${file}: Invalid port for SRV at index ${idx}` ); + t.true(isValidHostname(record.target), `${file}: Invalid target for SRV at index ${idx}`); } }); } @@ -268,60 +196,44 @@ function validateRecordValues(t, data, file) { if (key === "TXT") { const values = Array.isArray(value) ? value : [value]; values.forEach((record, idx) => { - t.true( - typeof record === "string", - `${file}: TXT record value should be a string at index ${idx}`, - ); + t.true(typeof record === "string", `${file}: TXT record value should be a string at index ${idx}`); }); } }); if (data.redirect_config) { - const customPaths = Object.keys( - data.redirect_config.custom_paths || {}, - ); + const customPaths = Object.keys(data.redirect_config.custom_paths || {}); const pathRegex = /^\/[a-zA-Z0-9\-_\.\/]+(? { - const customRedirectURL = - data.redirect_config.custom_paths[customPath]; + const customRedirectURL = data.redirect_config.custom_paths[customPath]; const urlMessage = `${file}: Custom path in redirect_config`; // Validate the custom path t.true( pathRegex.test(customPath), - `${urlMessage} must start with a slash, contain only alphanumeric characters, hyphens, underscores, periods, and slashes, and cannot end with a slash at index ${idx}`, + `${urlMessage} must start with a slash, contain only alphanumeric characters, hyphens, underscores, periods, and slashes, and cannot end with a slash at index ${idx}` ); t.true( customPath.length >= 2 && customPath.length <= 255, - `${urlMessage} should be 2-255 characters long at index ${idx}`, + `${urlMessage} should be 2-255 characters long at index ${idx}` ); // Validate the redirect URL t.true( data.record.URL !== customRedirectURL, - `${urlMessage} should be different from the URL record at index ${idx}`, + `${urlMessage} should be different from the URL record at index ${idx}` ); t.true( - customRedirectURL.startsWith("http://") || - customRedirectURL.startsWith("https://"), - `${urlMessage} must start with http:// or https:// at index ${idx}`, - ); - t.notThrows( - () => new URL(customRedirectURL), - `${urlMessage} contains an invalid URL at index ${idx}`, + customRedirectURL.startsWith("http://") || customRedirectURL.startsWith("https://"), + `${urlMessage} must start with http:// or https:// at index ${idx}` ); + t.notThrows(() => new URL(customRedirectURL), `${urlMessage} contains an invalid URL at index ${idx}`); // Check for self-referencing redirects const urlHost = new URL(customRedirectURL).host; - const isSelfReferencing = - file === "@.json" - ? urlHost === "is-a.dev" - : urlHost === `${subdomain}.is-a.dev`; - t.false( - isSelfReferencing, - `${urlMessage} cannot point to itself at index ${idx}`, - ); + const isSelfReferencing = file === "@.json" ? urlHost === "is-a.dev" : urlHost === `${subdomain}.is-a.dev`; + t.false(isSelfReferencing, `${urlMessage} cannot point to itself at index ${idx}`); }); } } @@ -332,51 +244,35 @@ t("All files should have valid record types", (t) => { const recordKeys = Object.keys(data.record); recordKeys.forEach((key) => { - t.true( - validateRecordType(key), - `${file}: Invalid record type: ${key}`, - ); + t.true(validateRecordType(key), `${file}: Invalid record type: ${key}`); }); // Record type combinations validation if (recordKeys.includes("CNAME") && !data.proxied) { - t.is( - recordKeys.length, - 1, - `${file}: CNAME records cannot be combined with other records unless proxied`, - ); + t.is(recordKeys.length, 1, `${file}: CNAME records cannot be combined with other records unless proxied`); } if (recordKeys.includes("NS")) { t.true( - recordKeys.length === 1 || - (recordKeys.length === 2 && recordKeys.includes("DS")), - `${file}: NS records cannot be combined with other records, except for DS records`, + recordKeys.length === 1 || (recordKeys.length === 2 && recordKeys.includes("DS")), + `${file}: NS records cannot be combined with other records, except for DS records` ); } if (recordKeys.includes("DS")) { - t.true( - recordKeys.includes("NS"), - `${file}: DS records must be combined with NS records`, - ); + t.true(recordKeys.includes("NS"), `${file}: DS records must be combined with NS records`); } if (recordKeys.includes("URL")) { t.true( - !recordKeys.includes("A") && - !recordKeys.includes("AAAA") && - !recordKeys.includes("CNAME"), - `${file}: URL records cannot be combined with A, AAAA, or CNAME records`, + !recordKeys.includes("A") && !recordKeys.includes("AAAA") && !recordKeys.includes("CNAME"), + `${file}: URL records cannot be combined with A, AAAA, or CNAME records` ); } if (data.redirect_config) { t.true( recordKeys.includes("URL") || data.proxied, - `${file}: Redirect config must be combined with a URL record or the domain must be proxied`, + `${file}: Redirect config must be combined with a URL record or the domain must be proxied` ); if (data.redirect_config.redirect_paths) { - t.true( - recordKeys.includes("URL"), - `${file}: redirect_config.redirect_paths requires a URL record`, - ); + t.true(recordKeys.includes("URL"), `${file}: redirect_config.redirect_paths requires a URL record`); } } diff --git a/tests/special.test.js b/tests/special.test.js index 3a5f8aeef..eb8ca52e0 100644 --- a/tests/special.test.js +++ b/tests/special.test.js @@ -3,24 +3,14 @@ const fs = require("fs-extra"); const path = require("path"); const domainsPath = path.resolve("domains"); -const files = fs - .readdirSync(domainsPath) - .filter((file) => file.endsWith(".json")); - -const bypassedUsernames = require("../util/bypassed.json").map((username) => - username.toLowerCase(), -); +const files = fs.readdirSync(domainsPath).filter((file) => file.endsWith(".json")); function getDomainData(subdomain) { try { - const data = fs.readJsonSync( - path.join(domainsPath, `${subdomain}.json`), - ); + const data = fs.readJsonSync(path.join(domainsPath, `${subdomain}.json`)); return data; } catch (error) { - throw new Error( - `Failed to read JSON for ${subdomain}: ${error.message}`, - ); + throw new Error(`Failed to read JSON for ${subdomain}: ${error.message}`); } } @@ -31,20 +21,15 @@ t("Users are limited to one single character subdomain", (t) => { const subdomain = file.replace(/\.json$/, ""); const data = getDomainData(subdomain); - if ( - subdomain.length === 1 && - !bypassedUsernames.includes(data.owner.username.toLowerCase()) - ) { + if (subdomain.length === 1 && data.owner.username.toLowerCase() !== "is-a-dev") { results.push({ subdomain, - owner: data.owner.username.toLowerCase(), + owner: data.owner.username.toLowerCase() }); } }); - const duplicates = results.filter( - (result) => results.filter((r) => r.owner === result.owner).length > 1, - ); + const duplicates = results.filter((result) => results.filter((r) => r.owner === result.owner).length > 1); const output = duplicates.reduce((acc, curr) => { if (!acc[curr.owner]) { acc[curr.owner] = []; @@ -59,7 +44,7 @@ t("Users are limited to one single character subdomain", (t) => { 0, Object.keys(output) .map((owner) => `${owner} - ${output[owner].join(", ")}`) - .join("\n"), + .join("\n") ); t.pass(); diff --git a/util/bypassed.json b/util/bypassed.json deleted file mode 100644 index 31fe6be0d..000000000 --- a/util/bypassed.json +++ /dev/null @@ -1 +0,0 @@ -["is-a-dev", "wdhdev"] diff --git a/util/reserved.json b/util/reserved.json index 8ac0501fd..c87796c32 100644 --- a/util/reserved.json +++ b/util/reserved.json @@ -105,6 +105,7 @@ "ops", "org", "organisation", + "organization", "owner", "pay", "payment", diff --git a/util/trusted.json b/util/trusted.json new file mode 100644 index 000000000..4b1b9b611 --- /dev/null +++ b/util/trusted.json @@ -0,0 +1,8 @@ +[ + "DEV-DIBSTER", + "iostpa", + "is-a-dev-bot", + "orangci", + "Stef-00012", + "wdhdev" +]