diff --git a/lib/extractor/docker-archive/index.ts b/lib/extractor/docker-archive/index.ts index 83e46005b..4d22203e1 100644 --- a/lib/extractor/docker-archive/index.ts +++ b/lib/extractor/docker-archive/index.ts @@ -1,24 +1,12 @@ -import { normalize as normalizePath } from "path"; -import { HashAlgorithm } from "../../types"; +import { + createGetImageIdFromManifest, + dockerArchiveConfig, + getManifestLayers, +} from "../generic-archive-extractor"; -import { DockerArchiveManifest } from "../types"; export { extractArchive } from "./layer"; -export function getManifestLayers(manifest: DockerArchiveManifest) { - return manifest.Layers.map((layer) => normalizePath(layer)); -} +export { getManifestLayers }; -export function getImageIdFromManifest( - manifest: DockerArchiveManifest, -): string { - try { - const imageId = manifest.Config.split(".")[0]; - if (imageId.includes(":")) { - // imageId includes the algorithm prefix - return imageId; - } - return `${HashAlgorithm.Sha256}:${imageId}`; - } catch (err) { - throw new Error("Failed to extract image ID from archive manifest"); - } -} +export const getImageIdFromManifest = + createGetImageIdFromManifest(dockerArchiveConfig); diff --git a/lib/extractor/docker-archive/layer.ts b/lib/extractor/docker-archive/layer.ts index a62c6c6c4..008c1968a 100644 --- a/lib/extractor/docker-archive/layer.ts +++ b/lib/extractor/docker-archive/layer.ts @@ -1,135 +1,6 @@ -import * as Debug from "debug"; -import { createReadStream } from "fs"; -import * as gunzip from "gunzip-maybe"; -import { basename, normalize as normalizePath } from "path"; -import { Readable } from "stream"; -import { extract, Extract } from "tar-stream"; -import { InvalidArchiveError } from ".."; -import { streamToJson } from "../../stream-utils"; -import { PluginOptions } from "../../types"; -import { extractImageLayer } from "../layer"; import { - DockerArchiveManifest, - ExtractAction, - ExtractedLayers, - ExtractedLayersAndManifest, - ImageConfig, -} from "../types"; + createExtractArchive, + dockerArchiveConfig, +} from "../generic-archive-extractor"; -const debug = Debug("snyk"); - -/** - * Retrieve the products of files content from the specified docker-archive. - * @param dockerArchiveFilesystemPath Path to image file saved in docker-archive format. - * @param extractActions Array of pattern-callbacks pairs. - * @param options PluginOptions - * @returns Array of extracted files products sorted by the reverse order of the layers from last to first. - */ -export async function extractArchive( - dockerArchiveFilesystemPath: string, - extractActions: ExtractAction[], - _options: Partial, -): Promise { - return new Promise((resolve, reject) => { - const tarExtractor: Extract = extract(); - const layers: Record = {}; - let manifest: DockerArchiveManifest; - let imageConfig: ImageConfig; - - tarExtractor.on("entry", async (header, stream, next) => { - if (header.type === "file") { - const normalizedName = normalizePath(header.name); - if (isTarFile(normalizedName)) { - try { - layers[normalizedName] = await extractImageLayer( - stream, - extractActions, - ); - } catch (error) { - debug(`Error extracting layer content from: '${error.message}'`); - reject(new Error("Error reading tar archive")); - } - } else if (isManifestFile(normalizedName)) { - const manifestArray = await getManifestFile( - stream, - ); - manifest = manifestArray[0]; - } else if (isImageConfigFile(normalizedName)) { - imageConfig = await getManifestFile(stream); - } - } - - stream.resume(); // auto drain the stream - next(); // ready for next entry - }); - - tarExtractor.on("finish", () => { - try { - resolve( - getLayersContentAndArchiveManifest(manifest, imageConfig, layers), - ); - } catch (error) { - debug( - `Error getting layers and manifest content from docker archive: ${error.message}`, - ); - reject(new InvalidArchiveError("Invalid Docker archive")); - } - }); - - tarExtractor.on("error", (error) => reject(error)); - - createReadStream(dockerArchiveFilesystemPath) - .pipe(gunzip()) - .pipe(tarExtractor); - }); -} - -function getLayersContentAndArchiveManifest( - manifest: DockerArchiveManifest, - imageConfig: ImageConfig, - layers: Record, -): ExtractedLayersAndManifest { - // skip (ignore) non-existent layers - // get the layers content without the name - // reverse layers order from last to first - const layersWithNormalizedNames = manifest.Layers.map((layersName) => - normalizePath(layersName), - ); - const filteredLayers = layersWithNormalizedNames - .filter((layersName) => layers[layersName]) - .map((layerName) => layers[layerName]) - .reverse(); - - if (filteredLayers.length === 0) { - throw new Error("We found no layers in the provided image"); - } - - return { - layers: filteredLayers, - manifest, - imageConfig, - }; -} - -/** - * Note: consumes the stream. - */ -async function getManifestFile(stream: Readable): Promise { - return streamToJson(stream); -} - -function isManifestFile(name: string): boolean { - return name === "manifest.json"; -} - -function isImageConfigFile(name: string): boolean { - const configRegex = new RegExp("[A-Fa-f0-9]{64}\\.json"); - return configRegex.test(name); -} - -function isTarFile(name: string): boolean { - // For both "docker save" and "skopeo copy" style archives the - // layers are represented as tar archives whose names end in .tar. - // For Docker this is "layer.tar", for Skopeo - ".tar". - return basename(name).endsWith(".tar"); -} +export const extractArchive = createExtractArchive(dockerArchiveConfig); diff --git a/lib/extractor/generic-archive-extractor.ts b/lib/extractor/generic-archive-extractor.ts new file mode 100644 index 000000000..a6387cfc2 --- /dev/null +++ b/lib/extractor/generic-archive-extractor.ts @@ -0,0 +1,167 @@ +import * as Debug from "debug"; +import { createReadStream } from "fs"; +import * as gunzip from "gunzip-maybe"; +import { basename, normalize as normalizePath } from "path"; +import { Readable } from "stream"; +import { extract, Extract } from "tar-stream"; +import { streamToJson } from "../stream-utils"; + +export class InvalidArchiveError extends Error { + constructor(message: string) { + super(); + this.name = "InvalidArchiveError"; + this.message = message; + } +} +import { HashAlgorithm, PluginOptions } from "../types"; +import { extractImageLayer } from "./layer"; +import { + ExtractAction, + ExtractedLayers, + ExtractedLayersAndManifest, + ImageConfig, + TarArchiveManifest, +} from "./types"; + +const debug = Debug("snyk"); + +export interface ArchiveConfig { + isLayerFile: (name: string) => boolean; + isImageConfigFile: (name: string) => boolean; + formatLabel: string; + layerErrorType: string; + extractImageId: (configValue: string) => string; +} + +export const dockerArchiveConfig: ArchiveConfig = { + isLayerFile: (name) => basename(name).endsWith(".tar"), + isImageConfigFile: (name) => new RegExp("[A-Fa-f0-9]{64}\\.json").test(name), + formatLabel: "Docker", + layerErrorType: "tar", + extractImageId: (configValue) => configValue.split(".")[0], +}; + +export const kanikoArchiveConfig: ArchiveConfig = { + isLayerFile: (name) => basename(name).endsWith(".tar.gz"), + isImageConfigFile: (name) => new RegExp("sha256:[A-Fa-f0-9]{64}").test(name), + formatLabel: "Kaniko", + layerErrorType: "tar.gz", + extractImageId: (configValue) => configValue, +}; + +export function createExtractArchive( + config: ArchiveConfig, +): ( + archiveFilesystemPath: string, + extractActions: ExtractAction[], + options: Partial, +) => Promise { + return (archiveFilesystemPath, extractActions, _options) => + new Promise((resolve, reject) => { + const tarExtractor: Extract = extract(); + const layers: Record = {}; + let manifest: TarArchiveManifest; + let imageConfig: ImageConfig; + + tarExtractor.on("entry", async (header, stream, next) => { + if (header.type === "file") { + const normalizedName = normalizePath(header.name); + if (config.isLayerFile(normalizedName)) { + try { + layers[normalizedName] = await extractImageLayer( + stream, + extractActions, + ); + } catch (error) { + debug(`Error extracting layer content from: '${error.message}'`); + reject( + new Error(`Error reading ${config.layerErrorType} archive`), + ); + } + } else if (isManifestFile(normalizedName)) { + const manifestArray = await getManifestFile( + stream, + ); + manifest = manifestArray[0]; + } else if (config.isImageConfigFile(normalizedName)) { + imageConfig = await getManifestFile(stream); + } + } + + stream.resume(); + next(); + }); + + tarExtractor.on("finish", () => { + try { + resolve(assembleLayersAndManifest(manifest, imageConfig, layers)); + } catch (error) { + debug( + `Error getting layers and manifest content from ${config.formatLabel} archive: ${error.message}`, + ); + reject( + new InvalidArchiveError(`Invalid ${config.formatLabel} archive`), + ); + } + }); + + tarExtractor.on("error", (error) => reject(error)); + + createReadStream(archiveFilesystemPath) + .on("error", (error) => reject(error)) + .pipe(gunzip()) + .pipe(tarExtractor); + }); +} + +function assembleLayersAndManifest( + manifest: TarArchiveManifest, + imageConfig: ImageConfig, + layers: Record, +): ExtractedLayersAndManifest { + const layersWithNormalizedNames = manifest.Layers.map((layerName) => + normalizePath(layerName), + ); + const filteredLayers = layersWithNormalizedNames + .filter((layerName) => layers[layerName]) + .map((layerName) => layers[layerName]) + .reverse(); + + if (filteredLayers.length === 0) { + throw new Error("We found no layers in the provided image"); + } + + return { + layers: filteredLayers, + manifest, + imageConfig, + }; +} + +async function getManifestFile(stream: Readable): Promise { + return streamToJson(stream); +} + +function isManifestFile(name: string): boolean { + return name === "manifest.json"; +} + +export function createGetImageIdFromManifest( + config: ArchiveConfig, +): (manifest: TarArchiveManifest) => string { + return (manifest) => { + try { + const imageId = config.extractImageId(manifest.Config); + if (imageId.includes(":")) { + return imageId; + } + return `${HashAlgorithm.Sha256}:${imageId}`; + } catch (err) { + throw new Error("Failed to extract image ID from archive manifest"); + } + }; +} + +export function getManifestLayers(manifest: TarArchiveManifest): string[] { + return manifest.Layers.map((layer) => normalizePath(layer)); +} diff --git a/lib/extractor/index.ts b/lib/extractor/index.ts index ff01fdce8..c25e17de7 100644 --- a/lib/extractor/index.ts +++ b/lib/extractor/index.ts @@ -7,7 +7,9 @@ import { import { AutoDetectedUserInstructions, ImageType } from "../types"; import { PluginOptions } from "../types"; import * as dockerExtractor from "./docker-archive"; +import { InvalidArchiveError } from "./generic-archive-extractor"; import * as kanikoExtractor from "./kaniko-archive"; +import { isWhitedOutFile } from "./layer"; import * as ociExtractor from "./oci-archive"; import { DockerArchiveManifest, @@ -23,13 +25,7 @@ import { const debug = Debug("snyk"); -export class InvalidArchiveError extends Error { - constructor(message) { - super(); - this.name = "InvalidArchiveError"; - this.message = message; - } -} +export { InvalidArchiveError } from "./generic-archive-extractor"; class ArchiveExtractor { private extractor: Extractor; private fileSystemPath: string; @@ -260,9 +256,7 @@ function layersWithLatestFileModifications( return extractedLayers; } -export function isWhitedOutFile(filename: string) { - return filename.match(/.wh./gm); -} +export { isWhitedOutFile } from "./layer"; function isBufferType(type: FileContent): type is Buffer { return (type as Buffer).buffer !== undefined; diff --git a/lib/extractor/kaniko-archive/index.ts b/lib/extractor/kaniko-archive/index.ts index 1c2f7cb84..b25a701ea 100644 --- a/lib/extractor/kaniko-archive/index.ts +++ b/lib/extractor/kaniko-archive/index.ts @@ -1,24 +1,12 @@ -import { normalize as normalizePath } from "path"; -import { HashAlgorithm } from "../../types"; +import { + createGetImageIdFromManifest, + getManifestLayers, + kanikoArchiveConfig, +} from "../generic-archive-extractor"; -import { KanikoArchiveManifest } from "../types"; export { extractArchive } from "./layer"; -export function getManifestLayers(manifest: KanikoArchiveManifest) { - return manifest.Layers.map((layer) => normalizePath(layer)); -} +export { getManifestLayers }; -export function getImageIdFromManifest( - manifest: KanikoArchiveManifest, -): string { - try { - const imageId = manifest.Config; - if (imageId.includes(":")) { - // imageId includes the algorithm prefix - return imageId; - } - return `${HashAlgorithm.Sha256}:${imageId}`; - } catch (err) { - throw new Error("Failed to extract image ID from archive manifest"); - } -} +export const getImageIdFromManifest = + createGetImageIdFromManifest(kanikoArchiveConfig); diff --git a/lib/extractor/kaniko-archive/layer.ts b/lib/extractor/kaniko-archive/layer.ts index 71d875063..5ed71f30a 100644 --- a/lib/extractor/kaniko-archive/layer.ts +++ b/lib/extractor/kaniko-archive/layer.ts @@ -1,133 +1,6 @@ -import * as Debug from "debug"; -import { createReadStream } from "fs"; -import * as gunzip from "gunzip-maybe"; -import { basename, normalize as normalizePath } from "path"; -import { Readable } from "stream"; -import { extract, Extract } from "tar-stream"; -import { InvalidArchiveError } from ".."; -import { streamToJson } from "../../stream-utils"; -import { PluginOptions } from "../../types"; -import { extractImageLayer } from "../layer"; import { - ExtractAction, - ImageConfig, - KanikoArchiveManifest, - KanikoExtractedLayers, - KanikoExtractedLayersAndManifest, -} from "../types"; + createExtractArchive, + kanikoArchiveConfig, +} from "../generic-archive-extractor"; -const debug = Debug("snyk"); - -/** - * Retrieve the products of files content from the specified kaniko-archive. - * @param kanikoArchiveFilesystemPath Path to image file saved in kaniko-archive format. - * @param extractActions Array of pattern-callbacks pairs. - * @param options PluginOptions - * @returns Array of extracted files products sorted by the reverse order of the layers from last to first. - */ -export async function extractArchive( - kanikoArchiveFilesystemPath: string, - extractActions: ExtractAction[], - _options: Partial, -): Promise { - return new Promise((resolve, reject) => { - const tarExtractor: Extract = extract(); - const layers: Record = {}; - let manifest: KanikoArchiveManifest; - let imageConfig: ImageConfig; - - tarExtractor.on("entry", async (header, stream, next) => { - if (header.type === "file") { - const normalizedName = normalizePath(header.name); - if (isTarGzFile(normalizedName)) { - try { - layers[normalizedName] = await extractImageLayer( - stream, - extractActions, - ); - } catch (error) { - debug(`Error extracting layer content from: '${error.message}'`); - reject(new Error("Error reading tar.gz archive")); - } - } else if (isManifestFile(normalizedName)) { - const manifestArray = await getManifestFile( - stream, - ); - - manifest = manifestArray[0]; - } else if (isImageConfigFile(normalizedName)) { - imageConfig = await getManifestFile(stream); - } - } - - stream.resume(); // auto drain the stream - next(); // ready for next entry - }); - - tarExtractor.on("finish", () => { - try { - resolve( - getLayersContentAndArchiveManifest(manifest, imageConfig, layers), - ); - } catch (error) { - debug( - `Error getting layers and manifest content from Kaniko archive: ${error.message}`, - ); - reject(new InvalidArchiveError("Invalid Kaniko archive")); - } - }); - - tarExtractor.on("error", (error) => reject(error)); - - createReadStream(kanikoArchiveFilesystemPath) - .pipe(gunzip()) - .pipe(tarExtractor); - }); -} - -function getLayersContentAndArchiveManifest( - manifest: KanikoArchiveManifest, - imageConfig: ImageConfig, - layers: Record, -): KanikoExtractedLayersAndManifest { - // skip (ignore) non-existent layers - // get the layers content without the name - // reverse layers order from last to first - const layersWithNormalizedNames = manifest.Layers.map((layersName) => - normalizePath(layersName), - ); - const filteredLayers = layersWithNormalizedNames - .filter((layersName) => layers[layersName]) - .map((layerName) => layers[layerName]) - .reverse(); - - if (filteredLayers.length === 0) { - throw new Error("We found no layers in the provided image"); - } - - return { - layers: filteredLayers, - manifest, - imageConfig, - }; -} - -/** - * Note: consumes the stream. - */ -async function getManifestFile(stream: Readable): Promise { - return streamToJson(stream); -} - -function isManifestFile(name: string): boolean { - return name === "manifest.json"; -} - -function isImageConfigFile(name: string): boolean { - const configRegex = new RegExp("sha256:[A-Fa-f0-9]{64}"); - return configRegex.test(name); -} - -function isTarGzFile(name: string): boolean { - return basename(name).endsWith(".tar.gz"); -} +export const extractArchive = createExtractArchive(kanikoArchiveConfig); diff --git a/lib/extractor/layer.ts b/lib/extractor/layer.ts index ca4b72081..a493ae3d8 100644 --- a/lib/extractor/layer.ts +++ b/lib/extractor/layer.ts @@ -2,11 +2,14 @@ import * as Debug from "debug"; import * as path from "path"; import { Readable } from "stream"; import { extract, Extract } from "tar-stream"; -import { isWhitedOutFile } from "."; import { applyCallbacks, isResultEmpty } from "./callbacks"; import { decompressMaybe } from "./decompress-maybe"; import { ExtractAction, ExtractedLayers } from "./types"; +export function isWhitedOutFile(filename: string) { + return filename.match(/.wh./gm); +} + const debug = Debug("snyk"); /** diff --git a/lib/extractor/types.ts b/lib/extractor/types.ts index d07b1ea2f..418ac7a22 100644 --- a/lib/extractor/types.ts +++ b/lib/extractor/types.ts @@ -42,13 +42,7 @@ export interface ExtractedLayers { [layerName: string]: FileNameAndContent; } -export interface ExtractedLayersAndManifest { - layers: ExtractedLayers[]; - manifest: DockerArchiveManifest | OciArchiveManifest; - imageConfig: ImageConfig; -} - -export interface DockerArchiveManifest { +export interface TarArchiveManifest { // Usually points to the JSON file in the archive that describes how the image was built. Config: string; RepoTags: string[]; @@ -56,6 +50,18 @@ export interface DockerArchiveManifest { Layers: string[]; } +// tslint:disable-next-line:no-empty-interface +export interface DockerArchiveManifest extends TarArchiveManifest {} + +// tslint:disable-next-line:no-empty-interface +export interface KanikoArchiveManifest extends TarArchiveManifest {} + +export interface ExtractedLayersAndManifest { + layers: ExtractedLayers[]; + manifest: TarArchiveManifest | OciArchiveManifest; + imageConfig: ImageConfig; +} + export interface ContainerConfig { User?: string | null; ExposedPorts?: { [port: string]: object } | null; @@ -112,35 +118,6 @@ export interface OciImageIndex { manifests: OciManifestInfo[]; } -export interface KanikoArchiveManifest { - // Usually points to the JSON file in the archive that describes how the image was built. - Config: string; - RepoTags: string[]; - // The names of the layers in this archive, usually in the format ".tar" or "/layer.tar". - Layers: string[]; -} - -export interface KanikoExtractionResult { - imageId: string; - manifestLayers: string[]; - extractedLayers: KanikoExtractedLayers; - rootFsLayers?: string[]; - autoDetectedUserInstructions?: AutoDetectedUserInstructions; - platform?: string; - imageLabels?: { [key: string]: string }; - imageCreationTime?: string; -} - -export interface KanikoExtractedLayers { - [layerName: string]: FileNameAndContent; -} - -export interface KanikoExtractedLayersAndManifest { - layers: KanikoExtractedLayers[]; - manifest: KanikoArchiveManifest; - imageConfig: ImageConfig; -} - export interface ExtractAction { // This name should be unique across all actions used. actionName: string; diff --git a/test/lib/extractor/generic-archive-extractor.spec.ts b/test/lib/extractor/generic-archive-extractor.spec.ts new file mode 100644 index 000000000..cd805090f --- /dev/null +++ b/test/lib/extractor/generic-archive-extractor.spec.ts @@ -0,0 +1,130 @@ +import { + createExtractArchive, + createGetImageIdFromManifest, + dockerArchiveConfig, + getManifestLayers, + kanikoArchiveConfig, +} from "../../../lib/extractor/generic-archive-extractor"; +import { + DockerArchiveManifest, + KanikoArchiveManifest, +} from "../../../lib/extractor/types"; +import { getFixture } from "../../util/index"; + +describe("generic archive extractor", () => { + describe("createExtractArchive", () => { + describe("with docker archive config", () => { + const extractArchive = createExtractArchive(dockerArchiveConfig); + + it("extracts layers and manifest from a docker archive", async () => { + const fixture = getFixture( + "docker-archives/docker-save/nginx-with-buildinfo.tar", + ); + const result = await extractArchive(fixture, [], {}); + expect(result.layers).toBeDefined(); + expect(result.manifest).toBeDefined(); + expect(result.imageConfig).toBeDefined(); + }); + }); + + describe("with kaniko archive config", () => { + const extractArchive = createExtractArchive(kanikoArchiveConfig); + + it("extracts layers and manifest from a kaniko archive", async () => { + const fixture = getFixture("kaniko-archives/kaniko-busybox.tar"); + const result = await extractArchive(fixture, [], {}); + expect(result.layers).toBeDefined(); + expect(result.manifest).toBeDefined(); + expect(result.imageConfig).toBeDefined(); + }); + }); + + it("rejects with an error when given a non-existent file", async () => { + const extractArchive = createExtractArchive(dockerArchiveConfig); + await expect( + extractArchive("non-existent.tar", [], {}), + ).rejects.toThrow(); + }); + }); + + describe("createGetImageIdFromManifest", () => { + describe("with docker archive config (strips .json extension)", () => { + const getImageIdFromManifest = + createGetImageIdFromManifest(dockerArchiveConfig); + + it("strips .json and returns imageId with sha256: prefix when prefix is present", () => { + const manifest: DockerArchiveManifest = { + Config: + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538.json", + RepoTags: [], + Layers: [], + }; + expect(getImageIdFromManifest(manifest)).toEqual( + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + ); + }); + + it("strips .json and prepends sha256: when prefix is absent", () => { + const manifest: DockerArchiveManifest = { + Config: + "2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538.json", + RepoTags: [], + Layers: [], + }; + expect(getImageIdFromManifest(manifest)).toEqual( + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + ); + }); + }); + + describe("with kaniko archive config (uses Config value directly)", () => { + const getImageIdFromManifest = + createGetImageIdFromManifest(kanikoArchiveConfig); + + it("returns imageId as-is when sha256: prefix is present", () => { + const manifest: KanikoArchiveManifest = { + Config: + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + RepoTags: [], + Layers: [], + }; + expect(getImageIdFromManifest(manifest)).toEqual( + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + ); + }); + + it("prepends sha256: when prefix is absent", () => { + const manifest: KanikoArchiveManifest = { + Config: + "2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + RepoTags: [], + Layers: [], + }; + expect(getImageIdFromManifest(manifest)).toEqual( + "sha256:2565821efb5e5b95b36541004fa0287732a11f97a4a0ff807cc065746f82538", + ); + }); + }); + + it("throws when Config is missing", () => { + const getImageIdFromManifest = + createGetImageIdFromManifest(dockerArchiveConfig); + const manifest = { Config: undefined, RepoTags: [], Layers: [] } as any; + expect(() => getImageIdFromManifest(manifest)).toThrow( + "Failed to extract image ID from archive manifest", + ); + }); + }); + + describe("getManifestLayers", () => { + it("normalizes layer paths", () => { + const manifest: DockerArchiveManifest = { + Config: "abc.json", + RepoTags: [], + Layers: ["a/b/../c/layer.tar", "d/layer.tar"], + }; + const result = getManifestLayers(manifest); + expect(result).toEqual(["a/c/layer.tar", "d/layer.tar"]); + }); + }); +});