From 057960bb62e87d438862246ffc6ee81a5652add4 Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Wed, 1 Dec 2021 16:13:51 -0500 Subject: [PATCH] Add util function for parsing durations and many more tests (#69) --- dist/main/index.js | 110 ++++++++++-- dist/post/index.js | 98 ++++++++++- src/base.ts | 4 +- src/client/credentials_json_client.ts | 4 +- src/client/workload_identity_client.ts | 4 +- src/utils.ts | 103 +++++++++++- tests/utils.test.ts | 224 ++++++++++++++++++++++++- 7 files changed, 505 insertions(+), 42 deletions(-) diff --git a/dist/main/index.js b/dist/main/index.js index 71ed36c..0f16b0b 100644 --- a/dist/main/index.js +++ b/dist/main/index.js @@ -610,7 +610,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.trimmedString = exports.fromBase64 = exports.toBase64 = exports.explodeStrings = exports.removeExportedCredentials = exports.writeSecureFile = void 0; +exports.parseDuration = exports.trimmedString = exports.fromBase64 = exports.toBase64 = exports.explodeStrings = exports.removeExportedCredentials = exports.writeSecureFile = void 0; const fs_1 = __webpack_require__(747); const crypto_1 = __importDefault(__webpack_require__(417)); const path_1 = __importDefault(__webpack_require__(622)); @@ -673,17 +673,38 @@ exports.removeExportedCredentials = removeExportedCredentials; * of trimmed strings. */ function explodeStrings(input) { - if (input == null || input.length === 0) { + if (!input || input.trim().length === 0) { return []; } const list = new Array(); - for (const line of input.split(`\n`)) { - for (const piece of line.split(',')) { - const entry = piece.trim(); - if (entry !== '') { - list.push(entry); - } + let curr = ''; + let escaped = false; + for (const ch of input) { + if (escaped) { + curr += ch; + escaped = false; + continue; } + switch (ch) { + case '\\': + escaped = true; + continue; + case ',': + case '\n': { + const val = curr.trim(); + if (val) { + list.push(val); + } + curr = ''; + break; + } + default: + curr += ch; + } + } + const val = curr.trim(); + if (val) { + list.push(val); } return list; } @@ -705,7 +726,7 @@ exports.toBase64 = toBase64; */ function fromBase64(s) { const str = s.replace(/-/g, '+').replace(/_/g, '/'); - while (str.length % 4) + while (s.length % 4) s += '='; return Buffer.from(str, 'base64').toString('utf8'); } @@ -718,6 +739,65 @@ function trimmedString(s) { return s ? s.trim() : ''; } exports.trimmedString = trimmedString; +/** + * parseDuration parses a user-supplied string duration with optional suffix and + * returns a number representing the number of seconds. It returns 0 when given + * the empty string. + * + * @param str Duration string + */ +function parseDuration(str) { + const given = (str || '').trim(); + if (!given) { + return 0; + } + let total = 0; + let curr = ''; + for (let i = 0; i < str.length; i++) { + const ch = str[i]; + switch (ch) { + case ' ': + continue; + case ',': + continue; + case 's': { + total += +curr; + curr = ''; + break; + } + case 'm': { + total += +curr * 60; + curr = ''; + break; + } + case 'h': { + total += +curr * 60 * 60; + curr = ''; + break; + } + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + curr += ch; + break; + default: + throw new SyntaxError(`Unsupported character "${ch}" at position ${i}`); + } + } + // Anything left over is seconds + if (curr) { + total += +curr; + } + return total; +} +exports.parseDuration = parseDuration; /***/ }), @@ -1901,8 +1981,8 @@ class CredentialsJSONClient { const signature = signer.sign(__classPrivateFieldGet(this, _CredentialsJSONClient_credentials, "f")['private_key']); return message + '.' + (0, utils_1.toBase64)(signature); } - catch (e) { - throw new Error(`Failed to sign auth token: ${e}`); + catch (err) { + throw new Error(`Failed to sign auth token using ${this.getServiceAccount()}: ${err}`); } }); } @@ -2190,8 +2270,8 @@ class BaseClient { expiration: parsed['expireTime'], }; } - catch (e) { - throw new Error(`Failed to generate Google Cloud access token for ${serviceAccount}: ${e}`); + catch (err) { + throw new Error(`Failed to generate Google Cloud access token for ${serviceAccount}: ${err}`); } }); } @@ -2267,8 +2347,8 @@ class WorkloadIdentityClient { return project; } /** - * getAuthToken generates a Google Cloud federated token using the provided OIDC - * token and Workload Identity Provider. + * getAuthToken generates a Google Cloud federated token using the provided + * OIDC token and Workload Identity Provider. */ getAuthToken() { return __awaiter(this, void 0, void 0, function* () { diff --git a/dist/post/index.js b/dist/post/index.js index 606b870..eeba608 100644 --- a/dist/post/index.js +++ b/dist/post/index.js @@ -449,7 +449,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.trimmedString = exports.fromBase64 = exports.toBase64 = exports.explodeStrings = exports.removeExportedCredentials = exports.writeSecureFile = void 0; +exports.parseDuration = exports.trimmedString = exports.fromBase64 = exports.toBase64 = exports.explodeStrings = exports.removeExportedCredentials = exports.writeSecureFile = void 0; const fs_1 = __webpack_require__(747); const crypto_1 = __importDefault(__webpack_require__(417)); const path_1 = __importDefault(__webpack_require__(622)); @@ -512,17 +512,38 @@ exports.removeExportedCredentials = removeExportedCredentials; * of trimmed strings. */ function explodeStrings(input) { - if (input == null || input.length === 0) { + if (!input || input.trim().length === 0) { return []; } const list = new Array(); - for (const line of input.split(`\n`)) { - for (const piece of line.split(',')) { - const entry = piece.trim(); - if (entry !== '') { - list.push(entry); - } + let curr = ''; + let escaped = false; + for (const ch of input) { + if (escaped) { + curr += ch; + escaped = false; + continue; } + switch (ch) { + case '\\': + escaped = true; + continue; + case ',': + case '\n': { + const val = curr.trim(); + if (val) { + list.push(val); + } + curr = ''; + break; + } + default: + curr += ch; + } + } + const val = curr.trim(); + if (val) { + list.push(val); } return list; } @@ -544,7 +565,7 @@ exports.toBase64 = toBase64; */ function fromBase64(s) { const str = s.replace(/-/g, '+').replace(/_/g, '/'); - while (str.length % 4) + while (s.length % 4) s += '='; return Buffer.from(str, 'base64').toString('utf8'); } @@ -557,6 +578,65 @@ function trimmedString(s) { return s ? s.trim() : ''; } exports.trimmedString = trimmedString; +/** + * parseDuration parses a user-supplied string duration with optional suffix and + * returns a number representing the number of seconds. It returns 0 when given + * the empty string. + * + * @param str Duration string + */ +function parseDuration(str) { + const given = (str || '').trim(); + if (!given) { + return 0; + } + let total = 0; + let curr = ''; + for (let i = 0; i < str.length; i++) { + const ch = str[i]; + switch (ch) { + case ' ': + continue; + case ',': + continue; + case 's': { + total += +curr; + curr = ''; + break; + } + case 'm': { + total += +curr * 60; + curr = ''; + break; + } + case 'h': { + total += +curr * 60 * 60; + curr = ''; + break; + } + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + curr += ch; + break; + default: + throw new SyntaxError(`Unsupported character "${ch}" at position ${i}`); + } + } + // Anything left over is seconds + if (curr) { + total += +curr; + } + return total; +} +exports.parseDuration = parseDuration; /***/ }), diff --git a/src/base.ts b/src/base.ts index 0149d24..8acaa26 100644 --- a/src/base.ts +++ b/src/base.ts @@ -138,8 +138,8 @@ export class BaseClient { accessToken: parsed['accessToken'], expiration: parsed['expireTime'], }; - } catch (e) { - throw new Error(`Failed to generate Google Cloud access token for ${serviceAccount}: ${e}`); + } catch (err) { + throw new Error(`Failed to generate Google Cloud access token for ${serviceAccount}: ${err}`); } } } diff --git a/src/client/credentials_json_client.ts b/src/client/credentials_json_client.ts index 18ee2f9..945cc19 100644 --- a/src/client/credentials_json_client.ts +++ b/src/client/credentials_json_client.ts @@ -96,8 +96,8 @@ export class CredentialsJSONClient implements AuthClient { const signature = signer.sign(this.#credentials['private_key']); return message + '.' + toBase64(signature); - } catch (e) { - throw new Error(`Failed to sign auth token: ${e}`); + } catch (err) { + throw new Error(`Failed to sign auth token using ${this.getServiceAccount()}: ${err}`); } } diff --git a/src/client/workload_identity_client.ts b/src/client/workload_identity_client.ts index 0135055..6bfeced 100644 --- a/src/client/workload_identity_client.ts +++ b/src/client/workload_identity_client.ts @@ -71,8 +71,8 @@ export class WorkloadIdentityClient implements AuthClient { } /** - * getAuthToken generates a Google Cloud federated token using the provided OIDC - * token and Workload Identity Provider. + * getAuthToken generates a Google Cloud federated token using the provided + * OIDC token and Workload Identity Provider. */ async getAuthToken(): Promise { const stsURL = new URL('https://sts.googleapis.com/v1/token'); diff --git a/src/utils.ts b/src/utils.ts index 114787a..da450eb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -62,19 +62,43 @@ export async function removeExportedCredentials(): Promise { * of trimmed strings. */ export function explodeStrings(input: string): Array { - if (input == null || input.length === 0) { + if (!input || input.trim().length === 0) { return []; } const list = new Array(); - for (const line of input.split(`\n`)) { - for (const piece of line.split(',')) { - const entry = piece.trim(); - if (entry !== '') { - list.push(entry); + let curr = ''; + let escaped = false; + for (const ch of input) { + if (escaped) { + curr += ch; + escaped = false; + continue; + } + + switch (ch) { + case '\\': + escaped = true; + continue; + case ',': + case '\n': { + const val = curr.trim(); + if (val) { + list.push(val); + } + curr = ''; + break; } + default: + curr += ch; } } + + const val = curr.trim(); + if (val) { + list.push(val); + } + return list; } @@ -95,7 +119,7 @@ export function toBase64(s: string | Buffer): string { */ export function fromBase64(s: string): string { const str = s.replace(/-/g, '+').replace(/_/g, '/'); - while (str.length % 4) s += '='; + while (s.length % 4) s += '='; return Buffer.from(str, 'base64').toString('utf8'); } @@ -103,6 +127,69 @@ export function fromBase64(s: string): string { * trimmedString returns a string trimmed of whitespace. If the input string is * null, then it returns the empty string. */ -export function trimmedString(s: string): string { +export function trimmedString(s: string | undefined | null): string { return s ? s.trim() : ''; } + +/** + * parseDuration parses a user-supplied string duration with optional suffix and + * returns a number representing the number of seconds. It returns 0 when given + * the empty string. + * + * @param str Duration string + */ +export function parseDuration(str: string): number { + const given = (str || '').trim(); + if (!given) { + return 0; + } + + let total = 0; + let curr = ''; + for (let i = 0; i < str.length; i++) { + const ch = str[i]; + switch (ch) { + case ' ': + continue; + case ',': + continue; + case 's': { + total += +curr; + curr = ''; + break; + } + case 'm': { + total += +curr * 60; + curr = ''; + break; + } + case 'h': { + total += +curr * 60 * 60; + curr = ''; + break; + } + + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + curr += ch; + break; + default: + throw new SyntaxError(`Unsupported character "${ch}" at position ${i}`); + } + } + + // Anything left over is seconds + if (curr) { + total += +curr; + } + + return total; +} diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 226d471..47f1a8a 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -4,12 +4,36 @@ import 'mocha'; import { expect } from 'chai'; import { tmpdir } from 'os'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; -import { removeExportedCredentials } from '../src/utils'; -import { writeSecureFile } from '../src/utils'; +import { + explodeStrings, + fromBase64, + parseDuration, + removeExportedCredentials, + toBase64, + trimmedString, + writeSecureFile, +} from '../src/utils'; + +describe('Utils', () => { + describe('#writeSecureFile', () => { + it('writes data to the file', async () => { + const tmp = tmpdir(); + const filePath = await writeSecureFile(tmp, 'hi'); + expect(existsSync(filePath)).to.be.true; + expect(readFileSync(filePath).toString('utf8')).to.eq('hi'); + }); + + it('generates a random name', async () => { + const tmp = tmpdir(); + const filePath1 = await writeSecureFile(tmp, 'hi'); + const filePath2 = await writeSecureFile(tmp, 'bye'); + expect(filePath1).to.not.eq(filePath2); + expect(filePath1).to.not.eq(filePath2); + }); + }); -describe('post', () => { describe('#removeExportedCredentials', () => { it('does nothing when GOOGLE_GHA_CREDS_PATH is unset', async () => { delete process.env.GOOGLE_GHA_CREDS_PATH; @@ -32,4 +56,196 @@ describe('post', () => { expect(pth).to.eq(''); }); }); + + describe('#explodeStrings', () => { + const cases = [ + { + name: 'empty string', + input: '', + expected: [], + }, + { + name: 'padded empty string', + input: ' ', + expected: [], + }, + { + name: 'comma-separated', + input: 'hello , world , and goodbye', + expected: ['hello', 'world', 'and goodbye'], + }, + { + name: 'newline-separated', + input: ` + hello + world + and goodbye`, + expected: ['hello', 'world', 'and goodbye'], + }, + { + name: 'comma and newline-separated', + input: ` + hello, + world, + and goodbye,`, + expected: ['hello', 'world', 'and goodbye'], + }, + { + name: 'comma-escaped', + input: 'hello , world , and\\, goodbye', + expected: ['hello', 'world', 'and, goodbye'], + }, + ]; + + cases.forEach((tc) => { + it(tc.name, async () => { + expect(explodeStrings(tc.input)).to.eql(tc.expected); + }); + }); + }); + + describe('#toBase64', () => { + const cases = [ + { + name: 'empty string', + input: '', + expected: '', + }, + { + name: 'empty buffer', + input: Buffer.from(''), + expected: '', + }, + { + name: 'encodes string', + input: 'hello', + expected: 'aGVsbG8', + }, + { + name: 'encodes buffer', + input: Buffer.from('hello'), + expected: 'aGVsbG8', + }, + ]; + + cases.forEach((tc) => { + it(tc.name, async () => { + expect(toBase64(tc.input)).to.eq(tc.expected); + }); + }); + }); + + describe('#fromBase64', () => { + const cases = [ + { + name: 'decodes', + input: 'aGVsbG8', + expected: 'hello', + }, + { + name: 'decodes padded', + input: 'aGVsbG8==', + expected: 'hello', + }, + ]; + + cases.forEach((tc) => { + it(tc.name, async () => { + expect(fromBase64(tc.input)).to.eq(tc.expected); + }); + }); + }); + + describe('#trimmedString', () => { + const cases = [ + { + name: 'null', + input: null, + expected: '', + }, + { + name: 'undefined', + input: undefined, + expected: '', + }, + { + name: 'empty string', + input: '', + expected: '', + }, + { + name: 'trims', + input: ' hello world ', + expected: 'hello world', + }, + ]; + + cases.forEach((tc) => { + it(tc.name, async () => { + expect(trimmedString(tc.input)).to.eq(tc.expected); + }); + }); + }); + + describe('#parseDuration', () => { + const cases = [ + { + name: 'empty string', + input: '', + expected: 0, + }, + { + name: 'unitless', + input: '149585', + expected: 149585, + }, + { + name: 'with commas', + input: '149,585', + expected: 149585, + }, + { + name: 'suffix seconds', + input: '149585s', + expected: 149585, + }, + { + name: 'suffix minutes', + input: '25m', + expected: 1500, + }, + { + name: 'suffix hours', + input: '12h', + expected: 43200, + }, + { + name: 'suffix hours minutes seconds', + input: '12h10m55s', + expected: 43855, + }, + { + name: 'commas and spaces', + input: '12h, 10m 55s', + expected: 43855, + }, + { + name: 'invalid', + input: '12h blueberries', + error: 'Unsupported character "b" at position 4', + }, + ]; + + cases.forEach((tc) => { + it(tc.name, async () => { + if (tc.expected) { + expect(parseDuration(tc.input)).to.eq(tc.expected); + } else if (tc.error) { + expect(() => { + parseDuration(tc.input); + }).to.throw(tc.error); + } + }); + }); + }); });