Source added

This commit is contained in:
Fr4nz D13trich 2025-11-20 09:26:33 +01:00
parent b2864b500e
commit ba28ca859e
8352 changed files with 1487182 additions and 1 deletions

View file

@ -0,0 +1,13 @@
plugins {
alias(libs.plugins.jetbrains.kotlin.jvm) apply false
}
buildscript {
repositories {
google()
mavenCentral()
}
}
apply(from = "${rootDir}/../constants.gradle.kts")

View file

@ -0,0 +1,44 @@
import org.gradle.kotlin.dsl.extra
plugins {
`kotlin-dsl`
alias(libs.plugins.ktlint)
id("groovy-gradle-plugin")
}
val signalJavaVersion: JavaVersion by rootProject.extra
val signalKotlinJvmTarget: String by rootProject.extra
java {
sourceCompatibility = signalJavaVersion
targetCompatibility = signalJavaVersion
}
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(signalKotlinJvmTarget))
}
compilerOptions {
suppressWarnings = true
}
}
dependencies {
implementation(libs.kotlin.gradle.plugin)
implementation(libs.android.library)
implementation(libs.android.application)
implementation(libs.ktlint)
implementation(project(":tools"))
// These allow us to reference the dependency catalog inside of our compiled plugins
implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location))
implementation(files(testLibs.javaClass.superclass.protectionDomain.codeSource.location))
}
ktlint {
filter {
exclude { element ->
element.file.path.contains("/build/generated-sources")
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
plugins {
id("org.jlleitschuh.gradle.ktlint")
}
ktlint {
version.set("1.2.1")
}

View file

@ -0,0 +1,223 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
import Licenses.LicenseData
import groovy.xml.XmlSlurper
import groovy.xml.slurpersupport.GPathResult
import org.gradle.internal.logging.text.StyledTextOutput
import org.gradle.internal.logging.text.StyledTextOutputFactory
import org.gradle.kotlin.dsl.support.serviceOf
import org.xml.sax.helpers.DefaultHandler
val out = project.serviceOf<StyledTextOutputFactory>().create("output")
/**
* Finds the licenses for all of our dependencies and saves them to app/src/main/res/raw/third_party_licenses.
*
* This task will fail if we cannot map an artifact's license to a known license in [Licenses]. If this happens,
* you need to manually save the new license, or map the URL to an existing license in [Licenses.getLicense].
*/
task("saveLicenses") {
description = "Finds the licenses for all of our dependencies and saves them to app/src/main/res/raw/third_party_licenses."
group = "Static Files"
doLast {
// We resolve all the artifacts, map them to a dependency that lets us fetch the POM metadata, and then use that to generate our models
val resolvedDependencies: List<ResolvedDependency> = configurations
.asSequence()
.filter { it.isCanBeResolved }
.mapNotNull { it.tryResolveConfiguration() }
.mapNotNull { it.tryToResolveArtifacts() }
.flatten()
.distinctBy { it.file }
.map { pomDependency(it.id.componentIdentifier.toString()) }
.mapNotNull { it.toResolvedDependency() }
.filter { it.licenses.isNotEmpty() }
.distinct()
.toList()
// Next we want to map each dependency to a known license, failing if we can't do so
val licenseToDependencies: MutableMap<LicenseData, MutableList<ResolvedDependency>> = mutableMapOf()
for (resolvedDependency in resolvedDependencies) {
for (license in resolvedDependency.licenses) {
val licenseData: LicenseData? = Licenses.getLicense(license.url)
if (licenseData == null) {
printlnError("Failed to find matching license data for ${license.name} (${license.url}), which is in use by $resolvedDependency")
throw RuntimeException("Failed to find matching license data! See output for details.")
}
licenseToDependencies.getOrPut(licenseData) { mutableListOf() } += resolvedDependency
}
}
// Now we can build the actual string that we'll write to the file
val output = StringBuilder()
licenseToDependencies
.entries
.sortedByDescending { it.value.size }
.forEach { entry ->
val license: LicenseData = entry.key
// Some companies have multiple artifacts that are named identically (and licensed identically).
// This just dedupes it based on the name+url so that you don't see the same name in the list twice.
val dependencies: List<ResolvedDependency> = entry.value.distinctBy { it.name + it.url }.sortedBy { it.name }
output.append("The following dependencies are licensed under ${license.name}:\n\n")
for (dependency in dependencies) {
output.append("* ${dependency.name}")
if (dependency.url != null) {
output.append(" (${dependency.url})")
}
output.append("\n")
}
output.append("\n")
output.append("==========================================================\n")
output.append("==========================================================\n")
output.append(license.text)
output.append("\n")
output.append("==========================================================\n")
output.append("==========================================================\n")
output.append("\n")
}
output.append("Kyber Patent License\n")
output.append("https://csrc.nist.gov/csrc/media/Projects/post-quantum-cryptography/documents/selected-algos-2022/nist-pqc-license-summary-and-excerpts.pdf\n")
output.append("\n")
// Save the file to disk
rootProject
.file("app/src/main/res/raw/third_party_licenses")
.writeText(output.toString())
printlnSuccess("Done!")
}
}
/**
* Converts a dependency for a POM to a [ResolvedDependency], which is just our nice and usable internal representation of all the data we need.
*
* @param leafDependency The actual dependency we're trying to resolve. If present, it means that [this] refers to a _parent_ of that dependency, which we're
* looking at just to try to get licensing information.
*/
fun Dependency.toResolvedDependency(leafDependency: ResolvedDependency? = null): ResolvedDependency? {
val pomConfiguration: Configuration = project.configurations.detachedConfiguration(this)
val pomFile: File = try {
pomConfiguration.resolve().first()
} catch (e: Exception) {
printlnWarning("[${this.id}] Failed to resolve the POM dependency to a file.")
return null
}
val xmlParser = XmlSlurper(true, false).apply {
errorHandler = DefaultHandler()
}
val xml: GPathResult = xmlParser.parse(pomFile)
val licenses: List<RawLicense> = try {
xml
.get("licenses")
?.get("license")
?.map { it as GPathResult }
?.map {
RawLicense(
name = it.get("name")?.text()?.trim() ?: "",
url = it.get("url")?.text()?.trim() ?: ""
)
}
?.filter {
it.name.isNotEmpty() && it.url.isNotEmpty()
} ?: emptyList()
} catch (e: Exception) {
printlnWarning("[${this.id}] Error when parsing XML")
e.printStackTrace()
emptyList()
}
// If we have a leafDependency, we just want to copy the possibly-found license into it, leaving the metadata alone, since that's the actual target of
// our search
val resolvedDependency = if (leafDependency != null) {
leafDependency.copy(licenses = licenses)
} else {
ResolvedDependency(
id = this.id,
name = xml.get("name")?.text()?.ifBlank { null } ?: xml.get("artifactId")?.text()?.ifBlank { null } ?: this.name,
url = xml.get("url")?.text()?.ifBlank { null },
licenses = licenses
)
}
// If there's no licenses, but a parent exists, then we can walk up the tree and try to find a license in a parent
if (resolvedDependency.licenses.isEmpty()) {
val parentGroup: String? = xml.get("parent")?.get("groupId")?.text()?.trim()
val parentName: String? = xml.get("parent")?.get("artifactId")?.text()?.trim()
val parentVersion: String? = xml.get("parent")?.get("version")?.text()?.trim()
if (parentGroup != null && parentName != null && parentVersion != null) {
printlnNormal("[${this.id}] Could not find a license on this node. Checking the parent.")
return pomDependency("$parentGroup:$parentName:$parentVersion").toResolvedDependency(leafDependency = resolvedDependency)
}
} else if (leafDependency != null) {
printlnNormal("[${leafDependency.id}] Found a license on a parent dependency. (parent = ${this.id})")
}
return resolvedDependency
}
fun printlnNormal(message: String) {
out.style(StyledTextOutput.Style.Normal).println(message)
}
fun printlnWarning(message: String) {
out.style(StyledTextOutput.Style.Description).println(message)
}
fun printlnSuccess(message: String) {
out.style(StyledTextOutput.Style.SuccessHeader).println(message)
}
fun printlnError(message: String) {
out.style(StyledTextOutput.Style.FailureHeader).println(message)
}
fun pomDependency(locator: String): Dependency {
return project.dependencies.create("$locator@pom")
}
fun Configuration.tryResolveConfiguration(): ResolvedConfiguration? {
return try {
this.resolvedConfiguration
} catch (e: Exception) {
null
}
}
fun ResolvedConfiguration.tryToResolveArtifacts(): Set<ResolvedArtifact>? {
return try {
this.resolvedArtifacts
} catch (e: Exception) {
null
}
}
fun GPathResult.get(key: String): GPathResult? {
return this.getProperty(key) as? GPathResult
}
val Dependency.id: String
get() = "${this.group}:${this.name}:${this.version}"
data class ResolvedDependency(
val id: String,
val name: String,
val url: String?,
val licenses: List<RawLicense>
)
data class RawLicense(val name: String, val url: String)

View file

@ -0,0 +1,70 @@
@file:Suppress("UnstableApiUsage")
import org.gradle.accessors.dm.LibrariesForLibs
import org.gradle.accessors.dm.LibrariesForTestLibs
import org.gradle.api.JavaVersion
import org.gradle.kotlin.dsl.extra
val libs = the<LibrariesForLibs>()
val testLibs = the<LibrariesForTestLibs>()
val signalBuildToolsVersion: String by rootProject.extra
val signalCompileSdkVersion: String by rootProject.extra
val signalTargetSdkVersion: Int by rootProject.extra
val signalMinSdkVersion: Int by rootProject.extra
val signalJavaVersion: JavaVersion by rootProject.extra
val signalKotlinJvmTarget: String by rootProject.extra
plugins {
// We cannot use the version catalog in the plugins block in convention plugins (it's not supported).
// Instead, plugin versions are controlled through the dependencies block in the build.gradle.kts.
id("com.android.library")
id("kotlin-android")
id("ktlint")
}
android {
buildToolsVersion = signalBuildToolsVersion
compileSdkVersion = signalCompileSdkVersion
defaultConfig {
minSdk = signalMinSdkVersion
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = signalJavaVersion
targetCompatibility = signalJavaVersion
}
kotlinOptions {
jvmTarget = signalKotlinJvmTarget
suppressWarnings = true
}
lint {
disable += "InvalidVectorPath"
}
}
dependencies {
lintChecks(project(":lintchecks"))
coreLibraryDesugaring(libs.android.tools.desugar)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.annotation)
implementation(libs.androidx.appcompat)
implementation(libs.rxjava3.rxandroid)
implementation(libs.rxjava3.rxjava)
implementation(libs.rxjava3.rxkotlin)
implementation(libs.kotlin.stdlib.jdk8)
ktlintRuleset(libs.ktlint.twitter.compose)
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.robolectric.robolectric)
testImplementation(testLibs.androidx.test.core)
testImplementation(testLibs.androidx.test.core.ktx)
}

View file

@ -0,0 +1,88 @@
@file:Suppress("UnstableApiUsage")
import org.gradle.accessors.dm.LibrariesForLibs
import org.gradle.accessors.dm.LibrariesForTestLibs
import org.gradle.api.JavaVersion
import org.gradle.kotlin.dsl.extra
import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.kotlin.dsl.the
val libs = the<LibrariesForLibs>()
val testLibs = the<LibrariesForTestLibs>()
val signalBuildToolsVersion: String by rootProject.extra
val signalCompileSdkVersion: String by rootProject.extra
val signalTargetSdkVersion: Int by rootProject.extra
val signalMinSdkVersion: Int by rootProject.extra
val signalJavaVersion: JavaVersion by rootProject.extra
val signalKotlinJvmTarget: String by rootProject.extra
plugins {
// We cannot use the version catalog in the plugins block in convention plugins (it's not supported).
// Instead, plugin versions are controlled through the dependencies block in the build.gradle.kts.
id("com.android.application")
id("kotlin-android")
id("ktlint")
}
android {
buildToolsVersion = signalBuildToolsVersion
compileSdkVersion = signalCompileSdkVersion
defaultConfig {
versionCode = 1
versionName = "1.0"
minSdk = signalMinSdkVersion
targetSdk = signalTargetSdkVersion
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = signalJavaVersion
targetCompatibility = signalJavaVersion
}
kotlinOptions {
jvmTarget = signalKotlinJvmTarget
suppressWarnings = true
}
buildFeatures {
buildConfig = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
}
dependencies {
coreLibraryDesugaring(libs.android.tools.desugar)
implementation(project(":core-util"))
coreLibraryDesugaring(libs.android.tools.desugar)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.annotation)
implementation(libs.androidx.appcompat)
implementation(libs.rxjava3.rxandroid)
implementation(libs.rxjava3.rxjava)
implementation(libs.rxjava3.rxkotlin)
implementation(libs.material.material)
implementation(libs.androidx.constraintlayout)
implementation(libs.kotlin.stdlib.jdk8)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.material3)
ktlintRuleset(libs.ktlint.twitter.compose)
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.robolectric.robolectric)
testImplementation(testLibs.androidx.test.core)
testImplementation(testLibs.androidx.test.core.ktx)
}

View file

@ -0,0 +1,128 @@
import groovy.io.FileType
import groovy.transform.stc.ClosureParams
import groovy.transform.stc.SimpleType
import org.signal.buildtools.StaticIpResolver
def allStringsResourceFiles(@ClosureParams(value = SimpleType.class, options = ['java.io.File']) Closure c) {
file('src/main/res').eachFileRecurse(FileType.FILES) { f ->
if (f.name == 'strings.xml') {
c(f)
}
}
}
task replaceEllipsis {
group 'Static Files'
description 'Process strings for ellipsis characters.'
doLast {
allStringsResourceFiles { f ->
def before = f.text
def after = f.text.replace('...', '…')
if (before != after) {
f.text = after
logger.info("$f.parentFile.name/$f.name...updated")
}
}
}
}
task cleanApostropheErrors {
group 'Static Files'
description 'Fix transifex apostrophe string errors.'
doLast {
allStringsResourceFiles { f ->
def before = f.text
def after = before.replaceAll(/([^\\=08])(')/, '$1\\\\\'')
if (before != after) {
f.text = after
logger.info("$f.parentFile.name/$f.name...updated")
}
}
}
}
task excludeNonTranslatables {
group 'Static Files'
description 'Remove strings that are marked "translatable"="false" or are ExtraTranslations.'
doLast {
def englishFile = file('src/main/res/values/strings.xml')
def english = new XmlParser().parse(englishFile)
def nonTranslatable = english
.findAll { it['@translatable'] == 'false' }
.collect { it['@name'] }
.toSet()
def all = english.collect { it['@name'] }.toSet()
def translatable = all - nonTranslatable
def inMultiline = false
def endBlockName = ""
allStringsResourceFiles { f ->
if (f != englishFile) {
def newLines = f.readLines()
.collect { line ->
if (!inMultiline) {
def singleLineMatcher = line =~ /name="([^"]*)".*(<\/|\/>)/
if (singleLineMatcher.find()) {
def name = singleLineMatcher.group(1)
if (!line.contains('excludeNonTranslatables') && !translatable.contains(name)) {
return " <!-- Removed by excludeNonTranslatables ${line.trim()} -->"
}
} else {
def multilineStartMatcher = line =~ /<(.*) .?name="([^"]*)".*/
if (multilineStartMatcher.find()) {
endBlockName = multilineStartMatcher.group(1)
def name = multilineStartMatcher.group(2)
if (!line.contains('excludeNonTranslatables') && !translatable.contains(name)) {
inMultiline = true;
return " <!-- Removed by excludeNonTranslatables ${line.trim()}"
}
}
}
} else {
def multilineEndMatcher = line =~ /<\/${endBlockName}/
if (multilineEndMatcher.find()) {
inMultiline = false
return "${line} -->"
}
}
return line
}
f.write(newLines.join("\n") + "\n")
}
}
}
}
task postTranslateQa {
group 'Static Files'
description 'Runs QA to check validity of updated strings, and ensure presence of any new languages in internal lists.'
dependsOn ':qa'
}
task resolveStaticIps {
group 'Static Files'
description 'Fetches static IPs for core hosts and writes them to static-ips.gradle'
doLast {
def staticIpResolver = new StaticIpResolver()
new File(projectDir, "static-ips.gradle.kts").text = """
rootProject.extra["service_ips"] = \"\"\"${staticIpResolver.resolveToBuildConfig("chat.signal.org")}\"\"\"
rootProject.extra["storage_ips"] = \"\"\"${staticIpResolver.resolveToBuildConfig("storage.signal.org")}\"\"\"
rootProject.extra["cdn_ips"] = \"\"\"${staticIpResolver.resolveToBuildConfig("cdn.signal.org")}\"\"\"
rootProject.extra["cdn2_ips"] = \"\"\"${staticIpResolver.resolveToBuildConfig("cdn2.signal.org")}\"\"\"
rootProject.extra["cdn3_ips"] = \"\"\"${staticIpResolver.resolveToBuildConfig("cdn3.signal.org")}\"\"\"
rootProject.extra["sfu_ips"] = \"\"\"${staticIpResolver.resolveToBuildConfig("sfu.voip.signal.org")}\"\"\"
rootProject.extra["content_proxy_ips"] = \"\"\"${staticIpResolver.resolveToBuildConfig("contentproxy.signal.org")}\"\"\"
rootProject.extra["svr2_ips"] = \"\"\"${staticIpResolver.resolveToBuildConfig("svr2.signal.org")}\"\"\"
rootProject.extra["cdsi_ips"] = \"\"\"${staticIpResolver.resolveToBuildConfig("cdsi.signal.org")}\"\"\"
""".stripIndent().trim() + "\n"
}
}
task updateStaticFilesAndQa {
group 'Static Files'
description 'Runs tasks to update static files. This includes translations, static IPs, and licenses. Runs QA afterwards to verify all went well. Intended to be run before cutting a release.'
dependsOn replaceEllipsis, cleanApostropheErrors, excludeNonTranslatables, resolveStaticIps, postTranslateQa
}

View file

@ -0,0 +1,29 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
create("testLibs") {
from(files("../gradle/test-libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
include(":plugins")
include(":tools")

View file

@ -0,0 +1,33 @@
plugins {
alias(libs.plugins.jetbrains.kotlin.jvm)
id("java-library")
alias(libs.plugins.ktlint)
}
val signalJavaVersion: JavaVersion by rootProject.extra
val signalKotlinJvmTarget: String by rootProject.extra
java {
sourceCompatibility = signalJavaVersion
targetCompatibility = signalJavaVersion
}
kotlin {
jvmToolchain {
languageVersion = JavaLanguageVersion.of(signalKotlinJvmTarget)
}
}
// NOTE: For now, in order to run ktlint on this project, you have to manually run ./gradlew :build-logic:tools:ktlintFormat
// Gotta figure out how to get it auto-included in the normal ./gradlew ktlintFormat
ktlint {
version.set("1.2.1")
}
dependencies {
implementation(gradleApi())
implementation(libs.dnsjava)
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.mockk)
}

View file

@ -0,0 +1,100 @@
package org.signal.buildtools
import org.xbill.DNS.ARecord
import org.xbill.DNS.Lookup
import org.xbill.DNS.Record
import org.xbill.DNS.SimpleResolver
import org.xbill.DNS.Type
import java.net.UnknownHostException
import kotlin.streams.toList
/**
* A tool to resolve hostname to static IPs.
* Feeds into our custom DNS resolver to provide a static IP fallback for our services.
*/
class StaticIpResolver @JvmOverloads constructor(
private val recordFetcher: RecordFetcher = RealRecordFetcher
) {
/**
* Resolves a hostname to a list of IPs, represented as a Java array declaration. e.g.
*
* ```java
* new String[]{"192.168.1.1", "192.168.1.2"}
* ```
*
* This is intended to be injected as a BuildConfig.
*/
fun resolveToBuildConfig(hostName: String): String {
val ips: List<String> = resolve(hostName)
val builder = StringBuilder()
builder.append("new String[]{")
ips.forEachIndexed { i, ip ->
builder.append("\"").append(ip).append("\"")
if (i < ips.size - 1) {
builder.append(",")
}
}
return builder.append("}").toString()
}
private fun resolve(hostname: String): List<String> {
val ips: MutableSet<String> = mutableSetOf()
// Run several resolves to mitigate DNS round robin
for (i in 1..10) {
ips.addAll(resolveOnce(hostname))
}
return ips.stream().sorted().toList()
}
private fun resolveOnce(hostName: String): List<String> {
try {
val records = recordFetcher.fetchRecords(hostName)
if (records != null) {
return records
.filter { it.type == Type.A }
.map { it as ARecord }
.map { it.address }
.map { it.hostAddress }
.filterNotNull()
} else {
throw IllegalStateException("Failed to resolve host! Lookup did not return any records.. $hostName")
}
} catch (e: UnknownHostException) {
throw IllegalStateException("Failed to resolve host! $hostName", e)
}
}
interface RecordFetcher {
fun fetchRecords(hostName: String): Array<Record>?
}
private object RealRecordFetcher : RecordFetcher {
override fun fetchRecords(hostName: String): Array<Record>? {
val resolver = SimpleResolver("1.1.1.1")
resolver.setTCP(true)
val lookup: Lookup = doLookup(hostName)
lookup.setResolver(resolver)
return lookup.run()
}
@Throws(UnknownHostException::class)
private fun doLookup(hostname: String): Lookup {
try {
return Lookup(hostname)
} catch (e: Throwable) {
throw UnknownHostException()
}
}
}
}

View file

@ -0,0 +1,59 @@
package org.signal.buildtools
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Test
import org.xbill.DNS.ARecord
import org.xbill.DNS.DClass
import org.xbill.DNS.Name
import org.xbill.DNS.Record
import java.net.Inet4Address
class StaticIpResolverTest {
companion object {
const val SIGNAL_DOT_ORG = "www.signal.org"
val SIGNAL_IP = byteArrayOf(123, 45, 67, 89)
val STRINGIFIED_IP = SIGNAL_IP.joinToString(".")
}
@Test
fun `Given a hostname with records, when I resolveToBuildConfig, then I expect a matching IP`() {
val staticIpResolver = StaticIpResolver(
FakeRecordFetcher(
mapOf(
SIGNAL_DOT_ORG to arrayOf(
ARecord(
Name.fromString("www."),
DClass.ANY,
0L,
mockk<Inet4Address> {
every { address } returns SIGNAL_IP
every { hostAddress } returns STRINGIFIED_IP
}
)
)
)
)
)
val actual = staticIpResolver.resolveToBuildConfig(SIGNAL_DOT_ORG)
val expected = """
new String[]{"$STRINGIFIED_IP"}
""".trimIndent()
assertEquals(expected, actual)
}
@Test(expected = IllegalStateException::class)
fun `Given a hostname without records, when I resolveToBuildConfig, then I expect`() {
val staticIpResolver = StaticIpResolver(FakeRecordFetcher(emptyMap()))
staticIpResolver.resolveToBuildConfig(SIGNAL_DOT_ORG)
}
private class FakeRecordFetcher(private val recordMap: Map<String, Array<Record>?>) : StaticIpResolver.RecordFetcher {
override fun fetchRecords(hostName: String): Array<Record>? {
return recordMap[hostName]
}
}
}