Skip to content

Commit

Permalink
feat: encodes non printables
Browse files Browse the repository at this point in the history
  • Loading branch information
addievo committed Nov 2, 2023
1 parent 59bc3e7 commit 970fbee
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 28 deletions.
69 changes: 53 additions & 16 deletions src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,36 @@ async function* arrayToAsyncIterable<T>(array: T[]): AsyncIterable<T> {
}
}

function encodeNonPrintable(str) {
return str.replace(/[\x00-\x1F\x7F-\x9F]/g, (char) => {
// Skip encoding for space (U+0020)
if (char === ' ') return char;
/*
This function:
1. Keeps regular spaces, only ' ', as they are.
2. Converts \n \r \t to escaped versions, \\n \\r and \\t.
3. Converts other control characters to their Unicode escape sequences.
*/

// Return the Unicode escape sequence
return `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`;
function encodeNonPrintable(input) {
// Ensure the input is a string
const str = String(input);
// eslint-disable-next-line
return str.replace(/[\x00-\x1F\x7F-\x9F]/g, (char) => {
switch (char) {
case ' ':
return char; // Preserve regular space
case '\n':
return '\\n'; // Encode newline
case '\r':
return '\\r'; // Encode carriage return
case '\t':
return '\\t'; // Encode tab
case '\v':
return '\\v'; // Encode tab
case '\f':
return '\\f'; // Encode tab
// Add cases for other whitespace characters if needed
default:
// Return the Unicode escape sequence for control characters
return `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`;
}
});
}

Expand All @@ -100,12 +123,22 @@ const outputTableFormatter = async (
? arrayToAsyncIterable(rowStream)
: rowStream;

const encodeRowValues = (row: TableRow) => {
for (const key in row) {
if (row[key] != null) {
// Convert the value to a string and encode non-printable characters
row[key] = encodeNonPrintable(row[key].toString());
}
}
};

const updateMaxColumnLengths = (row: TableRow) => {
encodeRowValues(row); // Encode the row values first
for (const [key, value] of Object.entries(row)) {
const cellValue = value == '' ? 'N/A' : value ?? 'N/A';
const cellValue = value === '' ? 'N/A' : value ?? 'N/A';
maxColumnLengths[key] = Math.max(
maxColumnLengths[key] || 0,
cellValue.toString().length,
cellValue.length, // Use the length of the encoded value
);
}
};
Expand Down Expand Up @@ -139,10 +172,9 @@ const outputTableFormatter = async (
const keysToUse = options?.headers ?? Object.keys(maxColumnLengths);

for (const key of keysToUse) {
// Assume row[key] has been already encoded
const cellValue = row[key] ?? 'N/A';
formattedRow += `${cellValue
?.toString()
.padEnd(maxColumnLengths[key] || 0)}\t`;
formattedRow += `${cellValue.padEnd(maxColumnLengths[key] || 0)}\t`;
}

return formattedRow.trimEnd();
Expand All @@ -162,12 +194,16 @@ function outputFormatter(
if (msg.type === 'raw') {
return msg.data;
} else if (msg.type === 'list') {
for (let elem in msg.data) {
// Empty string for null or undefined values
if (elem == null) {
elem = '';
for (let i = 0; i < msg.data.length; i++) {
let elem = msg.data[i];
// Encode non-printable characters for non-null/undefined elements
if (elem != null) {
// Check for both null and undefined
elem = encodeNonPrintable(elem.toString());
} else {
elem = ''; // Convert null or undefined to an empty string
}
output += `${msg.data[elem]}\n`;
output += `${elem}\n`;
}
} else if (msg.type === 'table') {
return outputTableFormatter(msg.data, msg.options);
Expand Down Expand Up @@ -326,6 +362,7 @@ export {
outputFormatter,
retryAuthentication,
remoteErrorCause,
encodeNonPrintable,
};

export type { OutputObject };
54 changes: 46 additions & 8 deletions tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@ import ErrorPolykey from 'polykey/dist/ErrorPolykey';
import * as ids from 'polykey/dist/ids';
import * as nodesUtils from 'polykey/dist/nodes/utils';
import * as polykeyErrors from 'polykey/dist/errors';
import * as fc from 'fast-check';
import * as binUtils from '@/utils/utils';
import * as testUtils from './utils';

const nonPrintableCharArbitrary = fc.oneof(
fc.integer({ min: 0, max: 0x1f }).map((code) => String.fromCharCode(code)),
fc.integer({ min: 0x7f, max: 0x9f }).map((code) => String.fromCharCode(code)),
);

const stringWithNonPrintablesArbitrary = fc.stringOf(
fc.oneof(fc.char(), nonPrintableCharArbitrary),
);
describe('bin/utils', () => {
testUtils.testIf(testUtils.isTestPlatformEmpty)(
'list in human and json format',
Expand All @@ -29,10 +38,7 @@ describe('bin/utils', () => {
testUtils.testIf(testUtils.isTestPlatformEmpty)(
'table in human and in json format',
async () => {
// Note the async here
// Table
const tableOutput = await binUtils.outputFormatter({
// And the await here
type: 'table',
data: [
{ key1: 'value1', key2: 'value2' },
Expand All @@ -41,12 +47,11 @@ describe('bin/utils', () => {
],
});
expect(tableOutput).toBe(
'value1\tvalue2\ndata1 \tdata2\nundefined\tundefined\n',
'value1\tvalue2\n' + 'data1 \tdata2\n' + 'N/A \tN/A\n',
);

// JSON
const jsonOutput = await binUtils.outputFormatter({
// And the await here
type: 'json',
data: [
{ key1: 'value1', key2: 'value2' },
Expand All @@ -67,19 +72,19 @@ describe('bin/utils', () => {
type: 'dict',
data: { key1: 'value1', key2: 'value2' },
}),
).toBe('key1\tvalue1\nkey2\tvalue2\n');
).toBe('key1\t"value1"\nkey2\t"value2"\n');
expect(
binUtils.outputFormatter({
type: 'dict',
data: { key1: 'first\nsecond', key2: 'first\nsecond\n' },
}),
).toBe('key1\tfirst\\nsecond\nkey2\tfirst\\nsecond\\n\n');
).toBe('key1\t"first\\nsecond"\nkey2\t"first\\nsecond\\n"\n');
expect(
binUtils.outputFormatter({
type: 'dict',
data: { key1: null, key2: undefined },
}),
).toBe('key1\t\nkey2\t\n');
).toBe('key1\t""\nkey2\t""\n');
// JSON
expect(
binUtils.outputFormatter({
Expand All @@ -89,6 +94,39 @@ describe('bin/utils', () => {
).toBe('{"key1":"value1","key2":"value2"}\n');
},
);
testUtils.testIf(testUtils.isTestPlatformEmpty)(
'outputFormatter should encode non-printable characters within a dict',
() => {
fc.assert(
fc.property(
stringWithNonPrintablesArbitrary,
stringWithNonPrintablesArbitrary,
(key, value) => {
const formattedOutput = binUtils.outputFormatter({
type: 'dict',
data: { [key]: value },
});

// Construct the expected output
let expectedValue = JSON.stringify(value);
expectedValue = binUtils.encodeNonPrintable(expectedValue);
expectedValue = expectedValue.replace(/(?:\r\n|\n)$/, '');
expectedValue = expectedValue.replace(/(\r\n|\n)/g, '$1\t');

const maxKeyLength = Math.max(
...Object.keys({ [key]: value }).map((k) => k.length),
);
const padding = ' '.repeat(maxKeyLength - key.length);
const expectedOutput = `${key}${padding}\t${expectedValue}\n`;

// Assert that the formatted output matches the expected output
expect(formattedOutput).toBe(expectedOutput);
},
),
{ numRuns: 100 }, // Number of times to run the test
);
},
);
testUtils.testIf(testUtils.isTestPlatformEmpty)(
'errors in human and json format',
() => {
Expand Down
10 changes: 6 additions & 4 deletions tests/vaults/vaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,11 +907,13 @@ describe('CLI vaults', () => {
cwd: dataDir,
});
expect(result3.exitCode).toBe(0);
expect(result3.stdout).toMatch(/Vault1\\t\\t.*\\t\\tclone/);
expect(result3.stdout).toContain(
`Vault1\t\t${vaultsUtils.encodeVaultId(vault1Id)}\t\tclone`,
);
expect(result3.stdout).toContain(
`Vault2\t\t${vaultsUtils.encodeVaultId(vault2Id)}\t\tpull,clone`,
`Vault1\\t\\t${vaultsUtils.encodeVaultId(
vault1Id,
)}\\t\\tclone\nVault2\\t\\t${vaultsUtils.encodeVaultId(
vault2Id,
)}\\t\\tpull,clone\n`,
);
expect(result3.stdout).not.toContain(
`Vault3\t\t${vaultsUtils.encodeVaultId(vault3Id)}`,
Expand Down

0 comments on commit 970fbee

Please sign in to comment.