Consolodate Logging;
This commit is contained in:
Родитель
24ccfc3019
Коммит
32a064bfff
|
@ -19,15 +19,5 @@
|
|||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"indent": ["error", 4],
|
||||
"require-jsdoc":"off",
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"spaced-comment": [
|
||||
"error",
|
||||
"always"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"eslint.alwaysShowStatus": true,
|
||||
"eslint.format.enable": true
|
||||
}
|
247
src/error.ts
247
src/error.ts
|
@ -1,220 +1,6 @@
|
|||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { LogLevels } from "./logger";
|
||||
|
||||
// eslint-disable-next-line no-var // TODO: ljoy, can we delete this?
|
||||
// export class OutputTree {
|
||||
// public child: OutputTree | undefined;
|
||||
// public infos: InfoItem[] = [];
|
||||
// public errors: ErrorItem[] = [];
|
||||
// private _exitCode = 0;
|
||||
|
||||
// constructor(public title: string = '') { }
|
||||
|
||||
// public get exitCode(): number {
|
||||
|
||||
// if (this.child) {
|
||||
// const childExitCode = this.child.exitCode;
|
||||
|
||||
// // the child should not return a different fatal exitCode
|
||||
// if (this._exitCode && (childExitCode === this._exitCode)) {
|
||||
// throw new Error("Exit code overwritten. Should only have one fatal error.");
|
||||
// }
|
||||
|
||||
// // set this exit code to the child if it's currently 0
|
||||
// this._exitCode = this._exitCode || childExitCode;
|
||||
// }
|
||||
|
||||
// return this._exitCode;
|
||||
// }
|
||||
|
||||
// error(errorItemArray: ErrorItem[]): OutputTree;
|
||||
// error(message: string): OutputTree;
|
||||
// error(message: string, code: ErrorCode): OutputTree;
|
||||
// error(message: string, code: ErrorCode, fatal: boolean): OutputTree;
|
||||
// error(message: string | ErrorItem[], code: ErrorCode = ErrorCode.ERROR, fatal = false): OutputTree {
|
||||
|
||||
// if (typeof message === 'string') {
|
||||
|
||||
// if (code == null || code === 0) {
|
||||
// throw new Error("Non-zero error code required.");
|
||||
// }
|
||||
|
||||
// if (fatal && this._exitCode !== 0) {
|
||||
// throw new Error("Exit code overwritten. Should only have one fatal error.");
|
||||
// }
|
||||
|
||||
// if (fatal) this._exitCode = code;
|
||||
|
||||
// this.errors.push(new ErrorItem(message, code, fatal));
|
||||
|
||||
// return this;
|
||||
// }
|
||||
|
||||
// for (let i = 0; i < message.length; i++) {
|
||||
// const err = message[i];
|
||||
|
||||
// if (err.fatal && this._exitCode !== 0) {
|
||||
// throw new Error("Exit code overwritten. Should only have one fatal error.");
|
||||
// }
|
||||
|
||||
// if (err.fatal) this._exitCode = err.code;
|
||||
|
||||
// this.errors.push(err);
|
||||
// }
|
||||
|
||||
// return this;
|
||||
// }
|
||||
|
||||
// info(message: string, code: InfoCode = InfoCode.INFO): OutputTree {
|
||||
// this.infos.push(new InfoItem(message, code));
|
||||
// return this;
|
||||
// }
|
||||
|
||||
// warn(message: string, code: InfoCode = InfoCode.INFO): OutputTree {
|
||||
// this.infos.push(new InfoItem(message, code));
|
||||
// return this;
|
||||
// }
|
||||
|
||||
// // collects errors from all children into a single collection
|
||||
// flatten(): { title: string, message: string, code: ErrorCode, fatal: boolean }[] {
|
||||
|
||||
// let errors = this.errors.map(e => {
|
||||
// return {
|
||||
// title: this.title,
|
||||
// message: e.message,
|
||||
// code: e.code,
|
||||
// fatal: e.fatal
|
||||
// };
|
||||
// });
|
||||
|
||||
// if (this.child) errors = errors.concat(this.child.flatten());
|
||||
// return errors;
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
// eslint-disable-next-line no-var
|
||||
export class OutputTree {
|
||||
public child: OutputTree | undefined;
|
||||
public log: LogItem[] = [];
|
||||
public result: FhirBundle | JWS | JWSPayload | HealthCard | undefined = undefined;
|
||||
private _exitCode = 0;
|
||||
|
||||
constructor(public title: string = '') { }
|
||||
|
||||
public get exitCode(): number {
|
||||
|
||||
if (this.child) {
|
||||
const childExitCode = this.child.exitCode;
|
||||
|
||||
// the child should not return a different fatal exitCode
|
||||
if (this._exitCode && (childExitCode === this._exitCode)) {
|
||||
throw new Error("Exit code overwritten. Should only have one fatal error.");
|
||||
}
|
||||
|
||||
// set this exit code to the child if it's currently 0
|
||||
this._exitCode = this._exitCode || childExitCode;
|
||||
}
|
||||
|
||||
return this._exitCode;
|
||||
}
|
||||
|
||||
debug(message: string): OutputTree {
|
||||
this.log.push(new LogItem(message, 0, LogLevels.DEBUG));
|
||||
return this;
|
||||
}
|
||||
|
||||
info(message: string): OutputTree {
|
||||
this.log.push(new LogItem(message, 0, LogLevels.INFO));
|
||||
return this;
|
||||
}
|
||||
|
||||
warn(message: string, code: ErrorCode = ErrorCode.ERROR): OutputTree {
|
||||
if (code == null || code === 0) {
|
||||
throw new Error("Non-zero error code required.");
|
||||
}
|
||||
this.log.push(new LogItem(message, code, LogLevels.WARNING));
|
||||
return this;
|
||||
}
|
||||
|
||||
error(message: string, code: ErrorCode = ErrorCode.ERROR): OutputTree {
|
||||
if (code == null || code === 0) {
|
||||
throw new Error("Non-zero error code required.");
|
||||
}
|
||||
this.log.push(new LogItem(message, code, LogLevels.ERROR));
|
||||
return this;
|
||||
}
|
||||
|
||||
fatal(message: string, code: ErrorCode = ErrorCode.ERROR): OutputTree {
|
||||
if (code == null || code === 0) {
|
||||
throw new Error("Non-zero error code required.");
|
||||
}
|
||||
if (this._exitCode !== 0) {
|
||||
throw new Error("Exit code overwritten. Should only have one fatal error.");
|
||||
}
|
||||
this._exitCode = code;
|
||||
this.log.push(new LogItem(message, code, LogLevels.FATAL));
|
||||
return this;
|
||||
}
|
||||
|
||||
add(logs: LogItem[]): OutputTree {
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
const item = logs[i];
|
||||
|
||||
switch (item.logLevel) {
|
||||
case LogLevels.DEBUG:
|
||||
return this.debug(item.message);
|
||||
|
||||
case LogLevels.INFO:
|
||||
return this.info(item.message);
|
||||
|
||||
case LogLevels.WARNING:
|
||||
return this.warn(item.message, item.code);
|
||||
|
||||
case LogLevels.ERROR:
|
||||
return this.error(item.message, item.code);
|
||||
|
||||
case LogLevels.FATAL:
|
||||
return this.fatal(item.message, item.code);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
get(level: LogLevels): LogItem[] {
|
||||
return this.log.filter(item => {
|
||||
return item.logLevel === level;
|
||||
});
|
||||
}
|
||||
|
||||
// collects errors from all children into a single collection
|
||||
flatten(level: LogLevels = LogLevels.DEBUG): { title: string, message: string, code: ErrorCode, level: LogLevels }[] {
|
||||
|
||||
let items = this.log
|
||||
.filter((item) => {
|
||||
return item.logLevel >= level;
|
||||
})
|
||||
.map(e => {
|
||||
return {
|
||||
title: this.title,
|
||||
message: e.message,
|
||||
code: e.code,
|
||||
level: e.logLevel
|
||||
};
|
||||
});
|
||||
|
||||
if (this.child) items = items.concat(this.child.flatten(level));
|
||||
return items;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class LogItem {
|
||||
constructor(public message: string, public code: ErrorCode = 0, public logLevel: LogLevels = LogLevels.INFO) { }
|
||||
}
|
||||
|
||||
export enum ErrorCode {
|
||||
ERROR = 100,
|
||||
DATA_FILE_NOT_FOUND,
|
||||
|
@ -230,25 +16,16 @@ export enum ErrorCode {
|
|||
JSON_PARSE_ERROR,
|
||||
CRITICAL_DATA_MISSING,
|
||||
JWS_TOO_LONG,
|
||||
INVALID_FILE_EXTENSION
|
||||
}
|
||||
|
||||
export enum InfoCode {
|
||||
INFO = 0
|
||||
}
|
||||
|
||||
export class ErrorWithCode extends Error {
|
||||
constructor(message: string, public code: ErrorCode) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class ResultWithErrors {
|
||||
public result: string | undefined = undefined;
|
||||
public errors: LogItem[] = [];
|
||||
|
||||
error(message: string, code: ErrorCode, level = LogLevels.ERROR): ResultWithErrors {
|
||||
this.errors.push(new LogItem(message, code, level));
|
||||
return this;
|
||||
}
|
||||
INVALID_FILE_EXTENSION,
|
||||
|
||||
INVALID_MISSING_KTY = 200,
|
||||
INVALID_WRONG_KTY,
|
||||
INVALID_MISSING_ALG,
|
||||
INVALID_WRONG_ALG,
|
||||
INVALID_MISSING_USE,
|
||||
INVALID_WRONG_USE,
|
||||
INVALID_MISSING_KID,
|
||||
INVALID_WRONG_KID,
|
||||
INVALID_SCHEMA,
|
||||
INVALID_UNKNOWN
|
||||
}
|
||||
|
|
|
@ -2,28 +2,32 @@
|
|||
// Licensed under the MIT license.
|
||||
|
||||
import * as utils from './utils';
|
||||
import {validateSchema} from './schema';
|
||||
import { OutputTree, ErrorCode } from './error';
|
||||
import { validateSchema } from './schema';
|
||||
import { ErrorCode } from './error';
|
||||
import fhirBundleSchema from '../schema/fhir-bundle-schema.json';
|
||||
import { Log } from './logger';
|
||||
import { ValidationResult } from './validate';
|
||||
|
||||
|
||||
export const schema = fhirBundleSchema;
|
||||
|
||||
export function validate(fhirBundleText: string): OutputTree {
|
||||
|
||||
const output = new OutputTree('FhirBundle');
|
||||
export function validate(fhirBundleText: string): ValidationResult {
|
||||
|
||||
const output = new Log('FhirBundle');
|
||||
|
||||
|
||||
const fhirBundle = utils.parseJson<FhirBundle>(fhirBundleText);
|
||||
if (fhirBundle === undefined) {
|
||||
return output
|
||||
.fatal("Failed to parse FhirBundle data as JSON.", ErrorCode.JSON_PARSE_ERROR);
|
||||
return {
|
||||
result: fhirBundle,
|
||||
log: output.fatal("Failed to parse FhirBundle data as JSON.", ErrorCode.JSON_PARSE_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// returns [] if successful
|
||||
const schemaResults = validateSchema(fhirBundleSchema, fhirBundle);
|
||||
output.add(schemaResults);
|
||||
// failures will be recorded in the log. we can continue processing.
|
||||
validateSchema(fhirBundleSchema, fhirBundle, output);
|
||||
|
||||
|
||||
// to continue validation, we must have a jws-compact string to validate
|
||||
|
@ -32,11 +36,16 @@ export function validate(fhirBundleText: string): OutputTree {
|
|||
fhirBundle.entry.length === 0
|
||||
) {
|
||||
// The schema check above will list the expected properties/type
|
||||
return output.error("FhirBundle.entry[] required to contine.");
|
||||
return {
|
||||
result: fhirBundle,
|
||||
log: output.fatal("FhirBundle.entry[] required to contine.", ErrorCode.CRITICAL_DATA_MISSING)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
output.info("Fhir Bundle Contents:");
|
||||
output.info(JSON.stringify(fhirBundle, null, 2));
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
return { result: fhirBundle, log: output };
|
||||
}
|
||||
|
|
|
@ -3,25 +3,31 @@
|
|||
|
||||
import * as utils from './utils';
|
||||
import { validateSchema } from './schema';
|
||||
import { OutputTree, ErrorCode } from './error';
|
||||
import { ErrorCode } from './error';
|
||||
import healthCardSchema from '../schema/smart-health-card-schema.json';
|
||||
import * as jws from './jws-compact';
|
||||
import { Log } from './logger';
|
||||
import { ValidationResult } from './validate';
|
||||
|
||||
|
||||
export const schema = healthCardSchema;
|
||||
|
||||
export async function validate(healthCardText: string): Promise<OutputTree> {
|
||||
|
||||
const output = new OutputTree('SMART Health Card');
|
||||
export async function validate(healthCardText: string): Promise<ValidationResult> {
|
||||
|
||||
const log = new Log('SMART Health Card');
|
||||
|
||||
const healthCard = utils.parseJson<HealthCard>(healthCardText);
|
||||
if (healthCard == undefined) {
|
||||
return output
|
||||
.fatal("Failed to parse HealthCard data as JSON.", ErrorCode.JSON_PARSE_ERROR);
|
||||
return {
|
||||
result: healthCard,
|
||||
log: log.fatal("Failed to parse HealthCard data as JSON.", ErrorCode.JSON_PARSE_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
// returns [] if successful
|
||||
const schemaResults = validateSchema(healthCardSchema, healthCard);
|
||||
output.add(schemaResults);
|
||||
// failures will be recorded in the log. we can continue processing.
|
||||
validateSchema(healthCardSchema, healthCard, log);
|
||||
|
||||
|
||||
// to continue validation, we must have a jws-compact string to validate
|
||||
const vc = healthCard.verifiableCredential;
|
||||
|
@ -32,12 +38,16 @@ export async function validate(healthCardText: string): Promise<OutputTree> {
|
|||
typeof vc[0] !== 'string'
|
||||
) {
|
||||
// The schema check above will list the expected properties/type
|
||||
return output.fatal(
|
||||
"HealthCard.verifiableCredential[jws-compact] required to contine.",
|
||||
ErrorCode.CRITICAL_DATA_MISSING);
|
||||
return {
|
||||
result: healthCard,
|
||||
log: log.fatal("HealthCard.verifiableCredential[jws-compact] required to contine.", ErrorCode.CRITICAL_DATA_MISSING)
|
||||
}
|
||||
}
|
||||
|
||||
output.child = await jws.validate(vc[0]);
|
||||
|
||||
return output;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
log.child = (await jws.validate(vc[0])).log;
|
||||
|
||||
|
||||
return { result: healthCard, log: log };
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// Licensed under the MIT license.
|
||||
|
||||
import { validateSchema } from './schema';
|
||||
import { OutputTree, ErrorCode } from './error';
|
||||
import { ErrorCode } from './error';
|
||||
import jwsCompactSchema from '../schema/jws-schema.json';
|
||||
import * as jwsPayload from './jws-payload';
|
||||
import * as keys from './keys';
|
||||
|
@ -10,31 +10,38 @@ import pako from 'pako';
|
|||
import got from 'got/dist/source';
|
||||
import jose, { JWK } from 'node-jose';
|
||||
import path from 'path';
|
||||
import { Log } from './logger';
|
||||
import { ValidationResult } from './validate';
|
||||
|
||||
|
||||
const MAX_JWS_LENGTH = 1195;
|
||||
|
||||
|
||||
export const schema = jwsCompactSchema;
|
||||
|
||||
const MAX_JWS_LENGTH = 1195;
|
||||
|
||||
export async function validate(jws: string): Promise<OutputTree> {
|
||||
export async function validate(jws: JWS): Promise<ValidationResult> {
|
||||
|
||||
// the jws string is not JSON. It is base64url.base64url.base64url
|
||||
|
||||
const output = new OutputTree('JWS-compact');
|
||||
const log = new Log('JWS-compact');
|
||||
|
||||
|
||||
if (!/[0-9a-zA-Z_-]+\.[0-9a-zA-Z_-]+\.[0-9a-zA-Z_-]+/g.test(jws.trim())) {
|
||||
return output
|
||||
.fatal('Failed to parse JWS-compact data as \'base64url.base64url.base64url\' string.',
|
||||
ErrorCode.JSON_PARSE_ERROR);
|
||||
return {
|
||||
result: undefined,
|
||||
log: log.fatal('Failed to parse JWS-compact data as \'base64url.base64url.base64url\' string.', ErrorCode.JSON_PARSE_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (jws.length >= MAX_JWS_LENGTH) {
|
||||
output.error('JWS, at ' + jws.length.toString() + ' characters, exceeds max character length of ' + MAX_JWS_LENGTH.toString(), ErrorCode.JWS_TOO_LONG);
|
||||
log.error('JWS, at ' + jws.length.toString() + ' characters, exceeds max character length of ' + MAX_JWS_LENGTH.toString(), ErrorCode.JWS_TOO_LONG);
|
||||
}
|
||||
|
||||
// returns [] if successful
|
||||
const schemaResults = validateSchema(jwsCompactSchema, jws);
|
||||
output.add(schemaResults);
|
||||
|
||||
// failures will be recorded in the log. we can continue processing.
|
||||
validateSchema(jwsCompactSchema, jws, log);
|
||||
|
||||
|
||||
// split into header[0], payload[1], key[2]
|
||||
|
@ -45,9 +52,10 @@ export async function validate(jws: string): Promise<OutputTree> {
|
|||
let inflatedPayload;
|
||||
try {
|
||||
inflatedPayload = pako.inflateRaw(Buffer.from(rawPayload, 'base64'), { to: 'string' });
|
||||
log.info('JWS payload inflated');
|
||||
} catch (err) {
|
||||
// TODO: we should try non-raw inflate, or try to parse JSON directly (if they forgot to deflate) and continue, to report the exact error
|
||||
output.error(
|
||||
log.error(
|
||||
["Error inflating JWS payload. Did you use raw DEFLATE compression?",
|
||||
(err as string)].join('\n'),
|
||||
ErrorCode.INFLATION_ERROR);
|
||||
|
@ -55,41 +63,44 @@ export async function validate(jws: string): Promise<OutputTree> {
|
|||
|
||||
|
||||
// try to validate the payload (even if infation failed)
|
||||
output.child = jwsPayload.validate(inflatedPayload || rawPayload);
|
||||
const payload = output.child.result as JWSPayload;
|
||||
|
||||
const payloadResult = jwsPayload.validate(inflatedPayload || rawPayload);
|
||||
const payload = payloadResult.result as JWSPayload;
|
||||
log.child = payloadResult.log;
|
||||
|
||||
|
||||
// if we did not get a payload back, it failed to be parsed and we cannot extract the key url
|
||||
// so we can stop.
|
||||
// the jws-payload child will contain the parse errors.
|
||||
// The payload validation may have a Fatal error if
|
||||
if (payload == null) {
|
||||
return output;
|
||||
return { result: payload, log : log };
|
||||
}
|
||||
|
||||
|
||||
// Extract the key url
|
||||
if (!payload.iss) {
|
||||
// continue, since we might have the key we need in the global keystore
|
||||
output.error("Can't find 'iss' entry in JWS payload",
|
||||
log.error("Can't find 'iss' entry in JWS payload",
|
||||
ErrorCode.SCHEMA_ERROR);
|
||||
}
|
||||
|
||||
|
||||
// download the keys into the keystore. if it fails, continue an try to use whatever is in the keystore.
|
||||
await downloadKey(path.join(payload.iss, '/.well-known/jwks.json'), output);
|
||||
await downloadKey(path.join(payload.iss, '/.well-known/jwks.json'), log);
|
||||
|
||||
|
||||
if(await verifyJws(jws, output)) {
|
||||
output.info("JWS signature verified");
|
||||
if(await verifyJws(jws, log)) {
|
||||
log.info("JWS signature verified");
|
||||
}
|
||||
|
||||
|
||||
return output;
|
||||
// TODO: the result should probably be the expanded (non-compact) JWS object.
|
||||
|
||||
return { result: jws, log : log };
|
||||
}
|
||||
|
||||
|
||||
async function downloadKey(keyPath: string, log: OutputTree): Promise<JWK.Key[] | undefined> {
|
||||
async function downloadKey(keyPath: string, log: Log): Promise<JWK.Key[] | undefined> {
|
||||
|
||||
log.info("Retrieving issuer key from " + keyPath);
|
||||
|
||||
|
@ -110,7 +121,8 @@ async function downloadKey(keyPath: string, log: OutputTree): Promise<JWK.Key[]
|
|||
|
||||
}
|
||||
|
||||
async function verifyJws(jws: string, log: OutputTree): Promise<boolean> {
|
||||
|
||||
async function verifyJws(jws: string, log: Log): Promise<boolean> {
|
||||
|
||||
const verifier: jose.JWS.Verifier = jose.JWS.createVerify(keys.store);
|
||||
|
||||
|
@ -124,4 +136,4 @@ async function verifyJws(jws: string, log: OutputTree): Promise<boolean> {
|
|||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,31 +3,32 @@
|
|||
|
||||
import * as utils from './utils';
|
||||
import { validateSchema } from './schema';
|
||||
import { OutputTree, ErrorCode } from './error';
|
||||
import { ErrorCode } from './error';
|
||||
import jwsPayloadSchema from '../schema/smart-health-card-vc-schema.json';
|
||||
import * as fhirBundle from './fhirBundle';
|
||||
import { Log } from './logger';
|
||||
import { ValidationResult } from './validate';
|
||||
|
||||
|
||||
export const schema = jwsPayloadSchema;
|
||||
|
||||
export function validate(jwsPayloadText: string): OutputTree {
|
||||
|
||||
const output = new OutputTree('JWS.payload');
|
||||
export function validate(jwsPayloadText: string): ValidationResult {
|
||||
|
||||
const log = new Log('JWS.payload');
|
||||
|
||||
|
||||
const jwsPayload = utils.parseJson<JWSPayload>(jwsPayloadText);
|
||||
if (jwsPayload === undefined) {
|
||||
return output
|
||||
.fatal("Failed to parse JWS.payload data as JSON.", ErrorCode.JSON_PARSE_ERROR);
|
||||
return {
|
||||
result: jwsPayload,
|
||||
log: log.fatal("Failed to parse JWS.payload data as JSON.", ErrorCode.JSON_PARSE_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
// this will get passed back to the jws-compact validation so it can
|
||||
// pull out the url for the key
|
||||
output.result = jwsPayload;
|
||||
|
||||
// returns [] if successful
|
||||
const schemaResults = validateSchema(jwsPayloadSchema, jwsPayload);
|
||||
output.add(schemaResults);
|
||||
// failures will be recorded in the log. we can continue processing.
|
||||
validateSchema(jwsPayloadSchema, jwsPayload, log);
|
||||
|
||||
|
||||
// to continue validation, we must have a jws-compact string to validate
|
||||
|
@ -37,13 +38,16 @@ export function validate(jwsPayloadText: string): OutputTree {
|
|||
!jwsPayload.vc.credentialSubject.fhirBundle
|
||||
) {
|
||||
// The schema check above will list the expected properties/type
|
||||
return output.fatal("JWS.payload.vc.credentialSubject.fhirBundle{} required to contine.",
|
||||
ErrorCode.CRITICAL_DATA_MISSING);
|
||||
return {
|
||||
result: jwsPayload,
|
||||
log: log.fatal("JWS.payload.vc.credentialSubject.fhirBundle{} required to contine.", ErrorCode.CRITICAL_DATA_MISSING)
|
||||
}
|
||||
}
|
||||
|
||||
const fhirBundleText = JSON.stringify(jwsPayload.vc.credentialSubject.fhirBundle);
|
||||
|
||||
output.child = fhirBundle.validate(fhirBundleText);
|
||||
log.child = (fhirBundle.validate(fhirBundleText)).log;
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
return { result: jwsPayload, log: log };
|
||||
}
|
||||
|
|
188
src/logger.ts
188
src/logger.ts
|
@ -1,14 +1,14 @@
|
|||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { ErrorCode } from './error';
|
||||
|
||||
export enum ValidationErrors {
|
||||
UNKNOWN,
|
||||
// TODO: all errors
|
||||
|
||||
export class LogItem {
|
||||
constructor(public message: string, public code: ErrorCode = 0, public logLevel: LogLevels = LogLevels.INFO) { }
|
||||
}
|
||||
|
||||
|
||||
export enum LogLevels {
|
||||
// Print out everything
|
||||
DEBUG = 0,
|
||||
|
@ -22,132 +22,94 @@ export enum LogLevels {
|
|||
FATAL
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevels,
|
||||
validationError: ValidationErrors,
|
||||
message: string,
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs application messages. Each message has a level; the message will be
|
||||
* printed if its level is higher than the logger's verbosity.
|
||||
*/
|
||||
class Logger {
|
||||
// eslint-disable-next-line no-var
|
||||
export class Log {
|
||||
public child: Log | undefined;
|
||||
public log: LogItem[] = [];
|
||||
private _exitCode = 0;
|
||||
|
||||
/**
|
||||
* Constructs a logger.
|
||||
* @param level mininum verbosity level to log
|
||||
* @param outFilePath path to output the logs to if specified, console otherwise
|
||||
*/
|
||||
constructor(level: LogLevels, outFilePath?: string) {
|
||||
this._verbosity = level;
|
||||
constructor(public title: string = '') { }
|
||||
|
||||
if (outFilePath) {
|
||||
// create new console that writes to file
|
||||
outFilePath = path.normalize(outFilePath);
|
||||
const ws = fs.createWriteStream(outFilePath);
|
||||
this._console = new console.Console(ws, ws);
|
||||
this._usesStdOut = false;
|
||||
public get exitCode(): number {
|
||||
|
||||
if (this.child) {
|
||||
const childExitCode = this.child.exitCode;
|
||||
|
||||
// the child should not return a different fatal exitCode
|
||||
if (this._exitCode && (childExitCode === this._exitCode)) {
|
||||
throw new Error("Exit code overwritten. Should only have one fatal error.");
|
||||
}
|
||||
|
||||
// set this exit code to the child if it's currently 0
|
||||
this._exitCode = this._exitCode || childExitCode;
|
||||
}
|
||||
|
||||
return this._exitCode;
|
||||
}
|
||||
|
||||
_console: Console = console;
|
||||
_usesStdOut = true;
|
||||
_verbosity: LogLevels;
|
||||
_logEntries: LogEntry[] = [];
|
||||
_prefix = false;
|
||||
|
||||
get verbosity(): LogLevels {
|
||||
return this._verbosity;
|
||||
debug(message: string): Log {
|
||||
this.log.push(new LogItem(message, 0, LogLevels.DEBUG));
|
||||
return this;
|
||||
}
|
||||
|
||||
set verbosity(level: LogLevels) {
|
||||
if (!Number.isInteger(level) || level < LogLevels.DEBUG || level > LogLevels.FATAL) {
|
||||
throw new Error("Invalid verbosity level");
|
||||
}
|
||||
this._verbosity = level;
|
||||
info(message: string): Log {
|
||||
this.log.push(new LogItem(message, 0, LogLevels.INFO));
|
||||
return this;
|
||||
}
|
||||
|
||||
set prefix(on: boolean) {
|
||||
this._prefix = on;
|
||||
warn(message: string, code: ErrorCode = ErrorCode.ERROR): Log {
|
||||
if (code == null || code === 0) {
|
||||
throw new Error("Non-zero error code required.");
|
||||
}
|
||||
this.log.push(new LogItem(message, code, LogLevels.WARNING));
|
||||
return this;
|
||||
}
|
||||
|
||||
color(s: string, color: string): string {
|
||||
if (this._usesStdOut) {
|
||||
// color the output, only on STDOUT
|
||||
// if (color == 'red') {
|
||||
// s = '\x1b[31m' + s + '\x1b[0m'; // red - message - reset
|
||||
// } else if (color == 'yellow') {
|
||||
// s = '\x1b[33m' + s + '\x1b[0m'; // yellow - message - reset
|
||||
// } else {
|
||||
// // don't know what that is, leave as is
|
||||
// }
|
||||
error(message: string, code: ErrorCode = ErrorCode.ERROR): Log {
|
||||
if (code == null || code === 0) {
|
||||
throw new Error("Non-zero error code required.");
|
||||
}
|
||||
return s;
|
||||
this.log.push(new LogItem(message, code, LogLevels.ERROR));
|
||||
return this;
|
||||
}
|
||||
|
||||
fatal(message: string, code: ErrorCode = ErrorCode.ERROR): Log {
|
||||
if (code == null || code === 0) {
|
||||
throw new Error("Non-zero error code required.");
|
||||
}
|
||||
if (this._exitCode !== 0) {
|
||||
throw new Error("Exit code overwritten. Should only have one fatal error.");
|
||||
}
|
||||
this._exitCode = code;
|
||||
this.log.push(new LogItem(message, code, LogLevels.FATAL));
|
||||
return this;
|
||||
}
|
||||
|
||||
log(message: string, level: LogLevels = LogLevels.INFO, validationError?: ValidationErrors, details?: unknown): void {
|
||||
|
||||
if (!validationError) {
|
||||
validationError = ValidationErrors.UNKNOWN;
|
||||
}
|
||||
if (this._prefix) {
|
||||
message = level.toString() + ": " + message;
|
||||
}
|
||||
this._logEntries.push({
|
||||
level: level,
|
||||
validationError: validationError,
|
||||
message: message,
|
||||
details: details
|
||||
get(level: LogLevels): LogItem[] {
|
||||
return this.log.filter(item => {
|
||||
return item.logLevel === level;
|
||||
});
|
||||
if (level >= this._verbosity) {
|
||||
// print to console
|
||||
if (level == LogLevels.DEBUG || level == LogLevels.INFO) {
|
||||
this._console.log(message);
|
||||
} else if (level == LogLevels.WARNING) {
|
||||
this._console.log(this.color(message, 'yellow'));
|
||||
// this._console.log('\x1b[33m%s\x1b[0m', message); // yellow - message - reset
|
||||
} else if (level == LogLevels.ERROR || level == LogLevels.FATAL) {
|
||||
this._console.log(this.color(message, 'red'));
|
||||
// this._console.log('\x1b[31m%s\x1b[0m', message); // red - message - reset
|
||||
}
|
||||
if (details != null) {
|
||||
// log details on a separate call to avoid stringification
|
||||
this._console.log(details);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, level: LogLevels = LogLevels.ERROR, details?: unknown) {
|
||||
log(message, level, undefined, details);
|
||||
// collects errors from all children into a single collection; specify level to filter >= level
|
||||
flatten(level: LogLevels = LogLevels.DEBUG): { title: string, message: string, code: ErrorCode, level: LogLevels }[] {
|
||||
|
||||
let items = this.log
|
||||
.filter((item) => {
|
||||
return item.logLevel >= level;
|
||||
})
|
||||
.map(e => {
|
||||
return {
|
||||
title: this.title,
|
||||
message: e.message,
|
||||
code: e.code,
|
||||
level: e.logLevel
|
||||
};
|
||||
});
|
||||
|
||||
if (this.child) items = items.concat(this.child.flatten(level));
|
||||
return items;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const logger = new Logger(LogLevels.WARNING /* 'out.log'*/);
|
||||
|
||||
export default function log(message: string, level: LogLevels = LogLevels.INFO, validationError?: ValidationErrors, details?: unknown) : void {
|
||||
return logger.log(message, level, validationError, details);
|
||||
}
|
||||
|
||||
const logWrapper = function (level: LogLevels) {
|
||||
function f(message: string, validationError?: ValidationErrors, details?: unknown): void {
|
||||
return logger.log(message, level, validationError, details);
|
||||
}
|
||||
return f;
|
||||
};
|
||||
|
||||
log.debug = logWrapper(LogLevels.DEBUG);
|
||||
log.error = logWrapper(LogLevels.ERROR);
|
||||
log.fatal = logWrapper(LogLevels.FATAL);
|
||||
log.info = logWrapper(LogLevels.INFO);
|
||||
log.warn = logWrapper(LogLevels.WARNING);
|
||||
|
||||
log.setLevel = function (level: LogLevels) {
|
||||
logger.verbosity = level;
|
||||
};
|
||||
|
||||
log.setPrefix = function (on: boolean) {
|
||||
logger.prefix = on;
|
||||
};
|
108
src/qr.ts
108
src/qr.ts
|
@ -5,8 +5,9 @@ import svg2img from 'svg2img'; // svg files to image buffer
|
|||
import { PNG } from 'pngjs'; // png image file reader
|
||||
import jsQR from 'jsqr'; // qr image decoder
|
||||
import core from 'file-type/core';
|
||||
import {OutputTree, ResultWithErrors, ErrorCode, LogItem} from './error';
|
||||
import { ErrorCode } from './error';
|
||||
import * as jws from './jws-compact';
|
||||
import { Log } from './logger';
|
||||
|
||||
|
||||
interface FileInfo {
|
||||
|
@ -20,65 +21,79 @@ interface FileInfo {
|
|||
}
|
||||
|
||||
|
||||
export async function validate(qrSvg: FileInfo): Promise<OutputTree> {
|
||||
export async function validate(qrSvg: FileInfo): Promise<{result: JWS | undefined, log :Log}> {
|
||||
|
||||
const output = new OutputTree('QR code (' + (qrSvg.fileType as string) + ')');
|
||||
const log = new Log('QR code (' + (qrSvg.fileType as string) + ')');
|
||||
|
||||
const results = await decode(qrSvg);
|
||||
const results : JWS | undefined = await decode(qrSvg, log);
|
||||
|
||||
output.add(results.errors);
|
||||
results && await jws.validate(results);
|
||||
|
||||
if (results.result != null) {
|
||||
output.child = await jws.validate(results.result);
|
||||
}
|
||||
|
||||
return output;
|
||||
return { result: results, log : log };
|
||||
}
|
||||
|
||||
|
||||
// the svg data is turned into an image buffer. these values ensure that the resulting image is readable
|
||||
// by the QR image decoder.
|
||||
const svgImageWidth = 600;
|
||||
const svgImageHeight = 600;
|
||||
const svgImageQuality = 100;
|
||||
|
||||
|
||||
// TODO: find minimal values that cause the resulting image to fail decoding.
|
||||
|
||||
// Converts a SVG file into a QR image buffer (as if read from a image file)
|
||||
async function svgToImageBuffer(svgPath: string): Promise<Buffer> {
|
||||
async function svgToImageBuffer(svgPath: string, log: Log): Promise<Buffer> {
|
||||
|
||||
// TODO: create a test that causes failure here
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
svg2img(svgPath, { width: 600, height: 600, quality: 100 }, function (error: unknown, buffer: Buffer) {
|
||||
if (error) reject(error);
|
||||
resolve(buffer);
|
||||
});
|
||||
svg2img(svgPath, { width: svgImageWidth, height: svgImageHeight, quality: svgImageQuality },
|
||||
(error: unknown, buffer: Buffer) => {
|
||||
if (error) {
|
||||
log.fatal("Could not convert SVG to image. Error: " + (error as Error).message);
|
||||
reject(undefined);
|
||||
}
|
||||
resolve(buffer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Decode QR image buffer to base64 string
|
||||
function decodeQrBuffer(image: Buffer): ResultWithErrors {
|
||||
function decodeQrBuffer(image: Buffer, log: Log): JWS | undefined {
|
||||
|
||||
const result = new ResultWithErrors();
|
||||
const result: JWS | undefined = undefined;
|
||||
|
||||
const png = PNG.sync.read(image);
|
||||
|
||||
// TODO : create a test that causes failure here
|
||||
const code = jsQR(new Uint8ClampedArray(png.data.buffer), png.width, png.height);
|
||||
|
||||
if (code == null) {
|
||||
result.errors.push(new LogItem("Could not decode QR image.", ErrorCode.QR_DECODE_ERROR));
|
||||
log.fatal("Could not decode QR image.", ErrorCode.QR_DECODE_ERROR);
|
||||
return result;
|
||||
}
|
||||
|
||||
return shcToJws(code.data);
|
||||
return shcToJws(code.data, log);
|
||||
}
|
||||
|
||||
|
||||
function shcToJws(shc: string): ResultWithErrors {
|
||||
|
||||
const result = new ResultWithErrors();
|
||||
|
||||
if (!/^shc:\/\d+$/g.test(shc)) {
|
||||
return result.error("Invalid 'shc:/' header string", ErrorCode.INVALID_SHC_STRING);
|
||||
}
|
||||
function shcToJws(shc: string, log: Log): JWS | undefined {
|
||||
|
||||
const b64Offset = '-'.charCodeAt(0);
|
||||
const digitPairs = shc.match(/(\d\d?)/g);
|
||||
|
||||
// check the header, we can still process if the header is wrong.
|
||||
if (!/^shc:\//.test(shc)) {
|
||||
log.error("Invalid 'shc:/' header string", ErrorCode.INVALID_SHC_STRING);
|
||||
}
|
||||
|
||||
// check
|
||||
const digitPairs = shc.match(/(\d\d)+$/g);
|
||||
|
||||
if (digitPairs == null) {
|
||||
return result.error("Invalid 'shc:/' header string", ErrorCode.INVALID_SHC_STRING);
|
||||
// we cannot continue without any data
|
||||
log.fatal("Invalid shc data. Data should be an even numbered string of digits ([0-9][0-9])+", ErrorCode.INVALID_SHC_STRING);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// breaks string array of digit pairs into array of numbers: 'shc:/123456...' = [12,34,56]
|
||||
|
@ -88,38 +103,39 @@ function shcToJws(shc: string): ResultWithErrors {
|
|||
// merge the array into a single base64 string
|
||||
.join('');
|
||||
|
||||
result.result = jws;
|
||||
|
||||
return result;
|
||||
return jws;
|
||||
}
|
||||
|
||||
|
||||
// takes file path to QR data and returns base64 data
|
||||
async function decode(fileInfo: FileInfo): Promise<ResultWithErrors> {
|
||||
async function decode(fileInfo: FileInfo, log: Log): Promise<string | undefined> {
|
||||
|
||||
let svgBuffer;
|
||||
const result = new ResultWithErrors();
|
||||
//const result = new ResultWithErrors();
|
||||
|
||||
switch (fileInfo.fileType) {
|
||||
|
||||
case 'svg':
|
||||
svgBuffer = await svgToImageBuffer(fileInfo.buffer.toString());
|
||||
return decodeQrBuffer(svgBuffer);
|
||||
case 'svg':
|
||||
svgBuffer = await svgToImageBuffer(fileInfo.buffer.toString(), log);
|
||||
return svgBuffer && decodeQrBuffer(svgBuffer, log);
|
||||
|
||||
case 'shc':
|
||||
return Promise.resolve(shcToJws(fileInfo.buffer.toString()));
|
||||
case 'shc':
|
||||
return Promise.resolve(shcToJws(fileInfo.buffer.toString(), log));
|
||||
|
||||
case 'png':
|
||||
return decodeQrBuffer(fileInfo.buffer);
|
||||
case 'png':
|
||||
return decodeQrBuffer(fileInfo.buffer, log);
|
||||
|
||||
case 'jpg':
|
||||
return result.error("jpg : Not implemented", ErrorCode.NOT_IMPLEMENTED);
|
||||
case 'jpg':
|
||||
log.fatal("jpg : Not implemented", ErrorCode.NOT_IMPLEMENTED);
|
||||
return undefined;
|
||||
|
||||
case 'bmp':
|
||||
return result.error("bmp : Not implemented", ErrorCode.NOT_IMPLEMENTED);
|
||||
case 'bmp':
|
||||
log.fatal("bmp : Not implemented", ErrorCode.NOT_IMPLEMENTED);
|
||||
return undefined;
|
||||
|
||||
default:
|
||||
return result.error("Unknown data in file", ErrorCode.UNKNOWN_FILE_DATA);
|
||||
default:
|
||||
log.fatal("Unknown data in file", ErrorCode.UNKNOWN_FILE_DATA);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -3,19 +3,20 @@
|
|||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import log, { LogLevels } from './logger';
|
||||
import { ErrorCode, LogItem } from './error';
|
||||
import { Log } from './logger';
|
||||
import { ErrorCode } from './error';
|
||||
|
||||
|
||||
// http://json-schema.org/
|
||||
// https://github.com/ajv-validator/ajv
|
||||
import JsonValidator, { AnySchemaObject, ErrorObject } from "ajv";
|
||||
import JsonValidator, { AnySchemaObject } from "ajv";
|
||||
|
||||
|
||||
export async function validateFromFile(schemaPath: string, data: FhirBundle | JWS | JWSPayload | HealthCard): Promise<LogItem[]> {
|
||||
export async function validateFromFile(schemaPath: string, data: FhirBundle | JWS | JWSPayload | HealthCard, log: Log): Promise<boolean> {
|
||||
|
||||
if (!fs.existsSync(schemaPath)) {
|
||||
log('Schema file not found : ' + schemaPath, LogLevels.FATAL);
|
||||
return [new LogItem('Schema: file not found : ' + schemaPath, ErrorCode.SCHEMA_FILE_NOT_FOUND)];
|
||||
log.fatal('Schema file not found : ' + schemaPath, ErrorCode.SCHEMA_ERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
const schemaDir = path.basename(path.dirname(schemaPath));
|
||||
|
@ -33,54 +34,48 @@ export async function validateFromFile(schemaPath: string, data: FhirBundle | JW
|
|||
|
||||
const schemaObj = JSON.parse(fs.readFileSync(schemaPath, 'utf8')) as AnySchemaObject;
|
||||
|
||||
|
||||
return jsonValidator.compileAsync(schemaObj)
|
||||
.then(validate => {
|
||||
|
||||
if (validate(data)) {
|
||||
return [];
|
||||
} else {
|
||||
const errors = (validate.errors as ErrorObject[]);
|
||||
|
||||
const outErrors = errors.map(c => {
|
||||
return new LogItem(
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
'Schema: ' + c.schemaPath + ' \'' + c.message + '\'',
|
||||
ErrorCode.SCHEMA_ERROR);
|
||||
});
|
||||
return outErrors;
|
||||
}
|
||||
if (validate(data)) { return true; }
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
validate.errors!.forEach(ve => {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
log.error('Schema: ' + ve.schemaPath + ' \'' + ve.message + '\'', ErrorCode.SCHEMA_ERROR);
|
||||
});
|
||||
return false;
|
||||
})
|
||||
.catch(error => {
|
||||
return [new LogItem(
|
||||
"Error: validating against schema : " + (error as Error).message,
|
||||
ErrorCode.SCHEMA_ERROR)];
|
||||
.catch(err => {
|
||||
// TODO: get to this catch in test
|
||||
log.error('Schema: ' + (err as Error).message, ErrorCode.SCHEMA_ERROR);
|
||||
return false;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function validateSchema(schema: AnySchemaObject, data: FhirBundle | JWS | JWSPayload | HealthCard): LogItem[] {
|
||||
export function validateSchema(schema: AnySchemaObject, data: FhirBundle | JWS | JWSPayload | HealthCard, log: Log): boolean {
|
||||
|
||||
// by default, the validator will stop at the first failure. 'allErrors' allows it to keep going.
|
||||
const jsonValidator = new JsonValidator({ allErrors: true });
|
||||
|
||||
try {
|
||||
|
||||
// TODO: make this fail in test
|
||||
const validate = jsonValidator.compile(schema);
|
||||
|
||||
if (validate(data)) { return []; }
|
||||
if (validate(data)) { return true; }
|
||||
|
||||
const validationErrors = (validate.errors as ErrorObject[]);
|
||||
|
||||
const outErrors = validationErrors.map(ve => {
|
||||
return new LogItem(
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
'Schema: ' + ve.schemaPath + ' \'' + ve.message + '\'',
|
||||
ErrorCode.SCHEMA_ERROR, LogLevels.ERROR);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
validate.errors!.forEach(ve => {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
log.error('Schema: ' + ve.schemaPath + ' \'' + ve.message + '\'', ErrorCode.SCHEMA_ERROR);
|
||||
});
|
||||
|
||||
return outErrors;
|
||||
return false;
|
||||
|
||||
} catch (err) {
|
||||
return [new LogItem('Schema: ' + (err as Error).message, ErrorCode.SCHEMA_ERROR)];
|
||||
// TODO: get to this catch in test
|
||||
log.error('Schema: ' + (err as Error).message, ErrorCode.SCHEMA_ERROR);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import path from 'path';
|
|||
import fs from 'fs';
|
||||
import { Option, Command } from 'commander';
|
||||
import * as validator from './validate';
|
||||
import log, { LogLevels } from './logger';
|
||||
import { LogLevels, Log } from './logger';
|
||||
import { getFileData } from './file';
|
||||
import { ErrorCode } from './error';
|
||||
import * as keys from './keys';
|
||||
|
@ -28,6 +28,7 @@ program.option('-o, --logout <path>', 'output path for log (if not specified log
|
|||
program.option('-k, --jwkset <key>', 'path to trusted issuer key set');
|
||||
program.parse(process.argv);
|
||||
|
||||
|
||||
interface Options {
|
||||
path: string;
|
||||
type: validator.ValidationType;
|
||||
|
@ -36,6 +37,10 @@ interface Options {
|
|||
logout: string;
|
||||
}
|
||||
|
||||
|
||||
const log = new Log('main');
|
||||
|
||||
|
||||
/**
|
||||
* Processes the program options and launches validation
|
||||
*/
|
||||
|
@ -46,16 +51,16 @@ async function processOptions() {
|
|||
// verify that the directory of the logfile exists
|
||||
if (options.logout) {
|
||||
const logDir = path.dirname(path.resolve(options.logout));
|
||||
if(!fs.existsSync(logDir)){
|
||||
if (!fs.existsSync(logDir)) {
|
||||
log.fatal('Cannot create log file at: ' + logDir);
|
||||
return;
|
||||
}
|
||||
logFilePathIsValid = true;
|
||||
}
|
||||
|
||||
if (options.loglevel) {
|
||||
log.setLevel(loglevelChoices.indexOf(options.loglevel) as LogLevels);
|
||||
}
|
||||
// if (options.loglevel) {
|
||||
// log.setLevel(loglevelChoices.indexOf(options.loglevel) as LogLevels);
|
||||
// }
|
||||
|
||||
if (options.path && options.type) {
|
||||
// read the file to validate
|
||||
|
@ -78,29 +83,28 @@ async function processOptions() {
|
|||
|
||||
if (options.type === 'jwkset') {
|
||||
// validate a key file
|
||||
await validator.validateKey(fileData.buffer);
|
||||
await validator.validateKey(fileData.buffer, log);
|
||||
|
||||
} else {
|
||||
// validate a health card
|
||||
const output = await validator.validateCard(fileData, options.type);
|
||||
process.exitCode = output.exitCode;
|
||||
process.exitCode = output.log.exitCode;
|
||||
|
||||
const level = loglevelChoices.indexOf(options.loglevel) as LogLevels;
|
||||
|
||||
|
||||
// append to the specified logfile
|
||||
if(logFilePathIsValid) {
|
||||
|
||||
if (logFilePathIsValid) {
|
||||
|
||||
const out = {
|
||||
"time" : new Date().toString(),
|
||||
"options" : options,
|
||||
"log" : output.flatten(level)
|
||||
"time": new Date().toString(),
|
||||
"options": options,
|
||||
"log": output.log.flatten(level)
|
||||
};
|
||||
fs.appendFileSync(options.logout, JSON.stringify(out, null, 4) + '\n');
|
||||
fs.appendFileSync(options.logout, '\n--------------------------------------------------------------------------------');
|
||||
|
||||
} else {
|
||||
if (output != null) {
|
||||
log(validator.formatOutput(output, '').join('\n'), level);
|
||||
}
|
||||
console.log(validator.formatOutput(output.log, '', level).join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,30 +1,16 @@
|
|||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
/* Validate SMART Health Card JSON Web Keys (JWK) */
|
||||
|
||||
import log, {LogLevels} from './logger';
|
||||
import { Log } from './logger';
|
||||
import jose, { JWK } from 'node-jose';
|
||||
import { ErrorCode } from './error';
|
||||
|
||||
export enum KeyValidationErrors {
|
||||
INVALID_MISSING_KTY,
|
||||
INVALID_WRONG_KTY,
|
||||
INVALID_MISSING_ALG,
|
||||
INVALID_WRONG_ALG,
|
||||
INVALID_MISSING_USE,
|
||||
INVALID_WRONG_USE,
|
||||
INVALID_MISSING_KID,
|
||||
INVALID_WRONG_KID,
|
||||
INVALID_SCHEMA,
|
||||
INVALID_UNKNOWN
|
||||
}
|
||||
|
||||
export class shcKeyValidator {
|
||||
|
||||
async verifyHealthCardIssuerKey(jwk: string | Buffer): Promise<KeyValidationErrors[]> {
|
||||
const validationResult : KeyValidationErrors[] = [];
|
||||
async verifyHealthCardIssuerKey(jwk: string | Buffer): Promise<Log> {
|
||||
|
||||
const log: Log = new Log('IssuerKey');
|
||||
const keyStore = JWK.createKeyStore();
|
||||
|
||||
return keyStore.add(jwk)
|
||||
|
@ -32,58 +18,48 @@ export class shcKeyValidator {
|
|||
.then(async key => {
|
||||
// check that key type is 'EC'
|
||||
if (!key.kty) {
|
||||
log("'kty' missing in issuer key", LogLevels.ERROR);
|
||||
validationResult.push(KeyValidationErrors.INVALID_MISSING_KTY);
|
||||
log.error("'kty' missing in issuer key", ErrorCode.INVALID_MISSING_KTY);
|
||||
} else if (key.kty !== 'EC') {
|
||||
log("wrong key type in issuer key. expected: 'EC', actual: " + key.kty, LogLevels.ERROR);
|
||||
validationResult.push(KeyValidationErrors.INVALID_WRONG_KTY);
|
||||
log.error("wrong key type in issuer key. expected: 'EC', actual: " + key.kty, ErrorCode.INVALID_WRONG_KTY);
|
||||
}
|
||||
|
||||
// check that EC curve is 'ES256'
|
||||
if (!key.alg) {
|
||||
log("'alg' missing in issuer key", LogLevels.ERROR);
|
||||
validationResult.push(KeyValidationErrors.INVALID_MISSING_ALG);
|
||||
log.error("'alg' missing in issuer key", ErrorCode.INVALID_MISSING_ALG);
|
||||
} else if (key.alg !== 'ES256') {
|
||||
log("wrong algorithm in issuer key. expected: 'ES256', actual: " + key.alg, LogLevels.ERROR);
|
||||
validationResult.push(KeyValidationErrors.INVALID_WRONG_ALG);
|
||||
log.error("wrong algorithm in issuer key. expected: 'ES256', actual: " + key.alg, ErrorCode.INVALID_WRONG_ALG);
|
||||
}
|
||||
|
||||
// check that usage is 'sig'
|
||||
if (!key.use) {
|
||||
log("'use' missing in issuer key", LogLevels.ERROR);
|
||||
validationResult.push(KeyValidationErrors.INVALID_MISSING_USE);
|
||||
log.error("'use' missing in issuer key", ErrorCode.INVALID_MISSING_USE);
|
||||
} else if (key.use !== 'sig') {
|
||||
log("wrong usage in issuer key. expected: 'sig', actual: " + key.use, LogLevels.ERROR);
|
||||
validationResult.push(KeyValidationErrors.INVALID_WRONG_USE);
|
||||
log.error("wrong usage in issuer key. expected: 'sig', actual: " + key.use, ErrorCode.INVALID_WRONG_USE);
|
||||
}
|
||||
|
||||
// check that kid is properly generated
|
||||
if (!key.kid) {
|
||||
log("'kid' missing in issuer key", LogLevels.ERROR);
|
||||
validationResult.push(KeyValidationErrors.INVALID_MISSING_KID);
|
||||
log.error("'kid' missing in issuer key", ErrorCode.INVALID_MISSING_KID);
|
||||
} else {
|
||||
await key.thumbprint('SHA-256')
|
||||
.then(tpDigest => {
|
||||
const thumbprint = jose.util.base64url.encode(tpDigest);
|
||||
if (key.kid !== thumbprint) {
|
||||
log("'kid' does not match thumbprint in issuer key. expected: "
|
||||
+ thumbprint + ", actual: " + key.kid, LogLevels.ERROR);
|
||||
validationResult.push(KeyValidationErrors.INVALID_WRONG_KID);
|
||||
log.error("'kid' does not match thumbprint in issuer key. expected: "
|
||||
+ thumbprint + ", actual: " + key.kid, ErrorCode.INVALID_WRONG_KID);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
log("Failed to calculate issuer key thumbprint", LogLevels.ERROR, err);
|
||||
validationResult.push(KeyValidationErrors.INVALID_UNKNOWN);
|
||||
log.error("Failed to calculate issuer key thumbprint : " + (err as Error).message, ErrorCode.INVALID_UNKNOWN);
|
||||
});
|
||||
}
|
||||
|
||||
return validationResult;
|
||||
return log;
|
||||
})
|
||||
.catch(err => {
|
||||
log("Failed to parse issuer key", LogLevels.ERROR, err);
|
||||
validationResult.push(KeyValidationErrors.INVALID_SCHEMA);
|
||||
return validationResult;
|
||||
log.error("Failed to parse issuer key : " + (err as Error).message, ErrorCode.INVALID_SCHEMA);
|
||||
return log;
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
104
src/validate.ts
104
src/validate.ts
|
@ -1,11 +1,11 @@
|
|||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import log, { LogLevels, logger } from './logger';
|
||||
import { LogItem, LogLevels, Log } from './logger';
|
||||
import color from 'colors';
|
||||
import { shcKeyValidator } from './shcKeyValidator';
|
||||
import { FileInfo } from './file';
|
||||
import { ErrorCode, LogItem, OutputTree } from './error';
|
||||
import { ErrorCode } from './error';
|
||||
import * as healthCard from './healthCard';
|
||||
import * as jws from './jws-compact';
|
||||
import * as jwsPayload from './jws-payload';
|
||||
|
@ -13,7 +13,6 @@ import * as fhirBundle from './fhirBundle';
|
|||
import * as qr from './qr';
|
||||
|
||||
|
||||
|
||||
function list(title: string, items: LogItem[], indent: string, color: (c: string) => string) {
|
||||
|
||||
const results: string[] = [];
|
||||
|
@ -33,33 +32,35 @@ function list(title: string, items: LogItem[], indent: string, color: (c: string
|
|||
return results;
|
||||
}
|
||||
|
||||
export function formatOutput(outputTree: OutputTree, indent: string): string[] {
|
||||
|
||||
export function formatOutput(outputTree: Log, indent: string, level : LogLevels): string[] {
|
||||
|
||||
let results: string[] = [];
|
||||
|
||||
results.push(indent + color.bold(outputTree.title));
|
||||
indent = ' ' + indent;
|
||||
|
||||
switch (logger.verbosity) {
|
||||
case LogLevels.DEBUG:
|
||||
results = results.concat(list("Debug", outputTree.get(LogLevels.DEBUG), indent + ' ', color.gray));
|
||||
switch (level) {
|
||||
|
||||
case LogLevels.DEBUG:
|
||||
results = results.concat(list("Debug", outputTree.get(LogLevels.DEBUG), indent + ' ', color.gray));
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case LogLevels.INFO:
|
||||
results = results.concat(list("Info", outputTree.get(LogLevels.INFO), indent + ' ', color.white.dim ));
|
||||
case LogLevels.INFO:
|
||||
results = results.concat(list("Info", outputTree.get(LogLevels.INFO), indent + ' ', color.white.dim));
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case LogLevels.WARNING:
|
||||
results = results.concat(list("Warning", outputTree.get(LogLevels.WARNING), indent + ' ', color.yellow));
|
||||
case LogLevels.WARNING:
|
||||
results = results.concat(list("Warning", outputTree.get(LogLevels.WARNING), indent + ' ', color.yellow));
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case LogLevels.ERROR:
|
||||
results = results.concat(list("Error", outputTree.get(LogLevels.ERROR), indent + ' ', color.red));
|
||||
case LogLevels.ERROR:
|
||||
results = results.concat(list("Error", outputTree.get(LogLevels.ERROR), indent + ' ', color.red));
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case LogLevels.FATAL:
|
||||
results = results.concat(list("Fatal", outputTree.get(LogLevels.FATAL), indent + ' ', color.red.inverse));
|
||||
case LogLevels.FATAL:
|
||||
results = results.concat(list("Fatal", outputTree.get(LogLevels.FATAL), indent + ' ', color.red.inverse));
|
||||
}
|
||||
|
||||
if (outputTree.child) {
|
||||
results.push(indent + ' |');
|
||||
results = results.concat(formatOutput(outputTree.child, indent));
|
||||
results = results.concat(formatOutput(outputTree.child, indent, level));
|
||||
} else {
|
||||
makeLeaf(results);
|
||||
}
|
||||
|
@ -80,59 +81,68 @@ function makeLeaf(items: string[]) {
|
|||
|
||||
|
||||
/** Validate the issuer key */
|
||||
export async function validateKey(key: Buffer): Promise<void> {
|
||||
export async function validateKey(key: Buffer, log: Log): Promise<void> {
|
||||
|
||||
log.debug('Validating key', undefined, key);
|
||||
log.debug('Validating key : ' + key.toString('utf-8'));
|
||||
|
||||
const keyValidator = new shcKeyValidator();
|
||||
|
||||
return keyValidator.verifyHealthCardIssuerKey(key)
|
||||
return keyValidator
|
||||
.verifyHealthCardIssuerKey(key)
|
||||
.then(() => { return Promise.resolve(); })
|
||||
.catch(err => {
|
||||
log.error("Error validating issuer key", undefined, err);
|
||||
log.error("Error validating issuer key : " + (err as Error).message);
|
||||
return Promise.reject();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export type ValidationType = "qr" | "qrnumeric" | "healthcard" | "jws" | "jwspayload" | "fhirbundle" | "jwkset";
|
||||
|
||||
/** Validates SMART Health Card */
|
||||
export async function validateCard(fileData: FileInfo, type: ValidationType): Promise<OutputTree> {
|
||||
|
||||
let output: OutputTree | undefined = undefined;
|
||||
export interface ValidationResult {
|
||||
result : HealthCard | JWS | JWSPayload | FhirBundle | undefined,
|
||||
log : Log
|
||||
}
|
||||
|
||||
|
||||
/** Validates SMART Health Card */
|
||||
export async function validateCard(fileData: FileInfo, type: ValidationType): Promise<ValidationResult> {
|
||||
|
||||
let result: ValidationResult;
|
||||
|
||||
switch (type.toLocaleLowerCase()) {
|
||||
|
||||
case "qr":
|
||||
output = await qr.validate(fileData);
|
||||
break;
|
||||
case "qr":
|
||||
result = await qr.validate(fileData);
|
||||
break;
|
||||
|
||||
case "qrnumeric":
|
||||
output = await qr.validate(fileData);
|
||||
break;
|
||||
case "qrnumeric":
|
||||
result = await qr.validate(fileData);
|
||||
break;
|
||||
|
||||
case "healthcard":
|
||||
output = await healthCard.validate(fileData.buffer.toString());
|
||||
if (fileData.ext !== '.smart-health-card') {
|
||||
output.warn("Invalid file extenion. Should be .smart-health-card.", ErrorCode.INVALID_FILE_EXTENSION);
|
||||
}
|
||||
break;
|
||||
case "healthcard":
|
||||
result = await healthCard.validate(fileData.buffer.toString());
|
||||
if (fileData.ext !== '.smart-health-card') {
|
||||
result.log.warn("Invalid file extenion. Should be .smart-health-card.", ErrorCode.INVALID_FILE_EXTENSION);
|
||||
}
|
||||
break;
|
||||
|
||||
case "jws":
|
||||
output = await jws.validate(fileData.buffer.toString());
|
||||
break;
|
||||
case "jws":
|
||||
result = await jws.validate(fileData.buffer.toString());
|
||||
break;
|
||||
|
||||
case "jwspayload":
|
||||
output = jwsPayload.validate(fileData.buffer.toString());
|
||||
break;
|
||||
case "jwspayload":
|
||||
result = jwsPayload.validate(fileData.buffer.toString());
|
||||
break;
|
||||
|
||||
case "fhirbundle":
|
||||
output = fhirBundle.validate(fileData.buffer.toString());
|
||||
break;
|
||||
case "fhirbundle":
|
||||
result = fhirBundle.validate(fileData.buffer.toString());
|
||||
break;
|
||||
|
||||
default:
|
||||
return Promise.reject("Invalid type : " + type);
|
||||
default:
|
||||
return Promise.reject("Invalid type : " + type);
|
||||
}
|
||||
|
||||
return output;
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -9,10 +9,10 @@ import { LogLevels } from '../src/logger';
|
|||
|
||||
const testdataDir = './testdata/';
|
||||
|
||||
async function testCard(fileName: string, fileType: ValidationType = 'healthcard', levels : LogLevels[] = [LogLevels.ERROR, LogLevels.FATAL]): Promise<{ title: string, message: string, code: ErrorCode }[]> {
|
||||
async function testCard(fileName: string, fileType: ValidationType = 'healthcard', levels: LogLevels[] = [LogLevels.ERROR, LogLevels.FATAL]): Promise<{ title: string, message: string, code: ErrorCode }[]> {
|
||||
const filePath = path.join(testdataDir, fileName);
|
||||
const outputTree = await validateCard(await getFileData(filePath), fileType);
|
||||
return outputTree.flatten().filter(i=>{return levels.includes(i.level);});
|
||||
const log = (await validateCard(await getFileData(filePath), fileType)).log;
|
||||
return log.flatten().filter(i => { return levels.includes(i.level); });
|
||||
}
|
||||
|
||||
// Test valid examples from spec
|
||||
|
@ -31,8 +31,8 @@ test("Cards: valid 01 JWS", async () => expect(await testCard('example-01-d-jws.
|
|||
test("Cards: valid 00 health card", async () => expect(await testCard('example-00-e-file.smart-health-card', "healthcard")).toHaveLength(0));
|
||||
test("Cards: valid 01 health card", async () => expect(await testCard('example-01-e-file.smart-health-card', "healthcard")).toHaveLength(0));
|
||||
|
||||
test("Cards: valid 00 QR numeric", async () => expect(await testCard('example-00-f-qr-code-numeric.txt', "qrnumeric")).toHaveLength(0));
|
||||
test("Cards: valid 01 QR numeric", async () => expect(await testCard('example-01-f-qr-code-numeric.txt', "qrnumeric")).toHaveLength(0));
|
||||
test("Cards: valid 00 QR numeric", async () => expect(await testCard('example-00-f-qr-code-numeric-value-0.txt', "qrnumeric")).toHaveLength(0));
|
||||
test("Cards: valid 01 QR numeric", async () => expect(await testCard('example-01-f-qr-code-numeric-value-0.txt', "qrnumeric")).toHaveLength(0));
|
||||
|
||||
test("Cards: valid 00 QR code", async () => expect(await testCard('example-00-g-qr-code-0.svg', "qr")).toHaveLength(0));
|
||||
test("Cards: valid 01 QR code", async () => expect(await testCard('example-01-g-qr-code-0.svg', "qr")).toHaveLength(0));
|
||||
|
|
|
@ -50,12 +50,12 @@ test("Cards: valid 00 health card", () => expect(testCliCommand('node . --path t
|
|||
test("Cards: valid 00 jws", () => expect(testCliCommand('node . --path testdata/example-00-d-jws.txt --type jws --loglevel info')).toBe(0));
|
||||
test("Cards: valid 00 jws-payload", () => expect(testCliCommand('node . --path testdata/example-00-c-jws-payload-minified.json --type jwspayload --loglevel info')).toBe(0));
|
||||
test("Cards: valid 00 fhirBundle", () => expect(testCliCommand('node . --path testdata/example-00-a-fhirBundle.json --type fhirbundle --loglevel info')).toBe(0));
|
||||
test("Cards: valid 00 qr-code-numeric", () => expect(testCliCommand('node . --path testdata/example-00-f-qr-code-numeric.txt --type qr --loglevel info')).toBe(0));
|
||||
test("Cards: valid 00 qr-code-numeric", () => expect(testCliCommand('node . --path testdata/example-00-f-qr-code-numeric-value-0.txt --type qr --loglevel info')).toBe(0));
|
||||
test("Cards: valid 01 health card", () => expect(testCliCommand('node . --path testdata/example-01-e-file.smart-health-card --type healthcard --loglevel warning')).toBe(0));
|
||||
test("Cards: valid 01 jws", () => expect(testCliCommand('node . --path testdata/example-01-d-jws.txt --type jws --loglevel warning')).toBe(0));
|
||||
test("Cards: valid 01 jws-payload", () => expect(testCliCommand('node . --path testdata/example-01-c-jws-payload-minified.json --type jwspayload --loglevel warning')).toBe(0));
|
||||
test("Cards: valid 01 fhirBundle", () => expect(testCliCommand('node . --path testdata/example-01-a-fhirBundle.json --type fhirbundle --loglevel warning')).toBe(0));
|
||||
test("Cards: valid 01 r-code-numeric", () => expect(testCliCommand('node . --path testdata/example-01-f-qr-code-numeric.txt --type qrnumeric --loglevel info')).toBe(0));
|
||||
test("Cards: valid 01 r-code-numeric", () => expect(testCliCommand('node . --path testdata/example-01-f-qr-code-numeric-value-0.txt --type qrnumeric --loglevel info')).toBe(0));
|
||||
test("Cards: valid 01 qr-code.svg", () => expect(testCliCommand('node . --path testdata/example-00-g-qr-code-0.svg --type qr --loglevel info')).toBe(0));
|
||||
test("Cards: valid qr.png", () => expect(testCliCommand('node . --path testdata/qr.png --type qr --loglevel info')).toBe(0));
|
||||
test("Cards: valid qr-90.pngd", () => expect(testCliCommand('node . --path testdata/qr-90.png --type qr --loglevel info')).toBe(0));
|
||||
|
|
|
@ -3,29 +3,37 @@
|
|||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {shcKeyValidator, KeyValidationErrors} from '../src/shcKeyValidator';
|
||||
import { ErrorCode } from '../src/error';
|
||||
import { shcKeyValidator } from '../src/shcKeyValidator';
|
||||
const testdataDir = './testdata/';
|
||||
|
||||
async function testKey(fileName: string): Promise<KeyValidationErrors[]> {
|
||||
async function testKey(fileName: string): Promise<ErrorCode[]> {
|
||||
const filePath = path.join(testdataDir, fileName);
|
||||
const keyValidator = new shcKeyValidator();
|
||||
return await keyValidator.verifyHealthCardIssuerKey(fs.readFileSync(filePath));
|
||||
const log = (await keyValidator.verifyHealthCardIssuerKey(fs.readFileSync(filePath))).log;
|
||||
return log.map(item => item.code);
|
||||
}
|
||||
|
||||
test("Keys: valid", async () => {
|
||||
expect(await testKey('valid_key.json')).toHaveLength(0);});
|
||||
expect(await testKey('valid_key.json')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("Keys: wrong key identifier (kid)", async () => {
|
||||
expect(await testKey('wrong_kid_key.json')).toContain(KeyValidationErrors.INVALID_WRONG_KID);});
|
||||
expect(await testKey('wrong_kid_key.json')).toContain(ErrorCode.INVALID_WRONG_KID);
|
||||
});
|
||||
|
||||
test("Keys: wrong elliptic curve", async () => {
|
||||
expect(await testKey('wrong_curve_key.json')).toContain(KeyValidationErrors.INVALID_WRONG_ALG);});
|
||||
expect(await testKey('wrong_curve_key.json')).toContain(ErrorCode.INVALID_WRONG_ALG);
|
||||
});
|
||||
|
||||
test("Keys: wrong key use (use)", async () => {
|
||||
expect(await testKey('wrong_use_key.json')).toContain(KeyValidationErrors.INVALID_WRONG_USE);});
|
||||
test("Keys: wrong key use (use)", async () => {
|
||||
expect(await testKey('wrong_use_key.json')).toContain(ErrorCode.INVALID_WRONG_USE);
|
||||
});
|
||||
|
||||
test("Keys: wrong algorithm (alg)", async () => {
|
||||
expect(await testKey('wrong_alg_key.json')).toContain(KeyValidationErrors.INVALID_WRONG_ALG);});
|
||||
expect(await testKey('wrong_alg_key.json')).toContain(ErrorCode.INVALID_WRONG_ALG);
|
||||
});
|
||||
|
||||
test("Keys: wrong key type (kty)", async () => {
|
||||
expect(await testKey('wrong_kty_key.json')).toContain(KeyValidationErrors.INVALID_WRONG_KTY);});
|
||||
expect(await testKey('wrong_kty_key.json')).toContain(ErrorCode.INVALID_WRONG_KTY);
|
||||
});
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { LogLevels, Log } from '../src/logger';
|
||||
import { validateFromFile } from '../src/schema';
|
||||
|
||||
|
||||
|
@ -74,10 +75,12 @@ function testSchema(exampleFile: string): void {
|
|||
const fileData = fs.readFileSync(examplePath, 'utf-8');
|
||||
const ext = path.extname(examplePath);
|
||||
const dataObj = ext !== '.txt' ? JSON.parse(fileData) as FhirBundle | JWS | JWSPayload | HealthCard : fileData;
|
||||
const log = new Log('testSchema');
|
||||
|
||||
test("Schema: " + schemaName + " " + exampleFile, async () => {
|
||||
const result = await validateFromFile(schemaPath, dataObj);
|
||||
expect(result.length).toBe(0);
|
||||
const result = await validateFromFile(schemaPath, dataObj, log);
|
||||
expect(result).toBe(true);
|
||||
expect(log.flatten(LogLevels.WARNING).length).toBe(0);
|
||||
});
|
||||
|
||||
return;
|
||||
|
|
Загрузка…
Ссылка в новой задаче