diff --git a/lib/classes/yaml-parser.js b/lib/classes/yaml-parser.js index b23b0c8e09..58c1a69263 100644 --- a/lib/classes/yaml-parser.js +++ b/lib/classes/yaml-parser.js @@ -3,6 +3,7 @@ const path = require('path'); const { pathToFileURL } = require('url'); const yaml = require('js-yaml'); +const cloudformationSchema = require('../utils/serverless-utils/cloudformation-schema'); const isPlainObject = require('type/plain-object/is'); const ServerlessError = require('../serverless-error'); const { isExternalRefAccessDeniedError } = require('./yaml-parser/external-ref-errors'); @@ -64,7 +65,9 @@ const loadExternalDocument = (documentUrl, state) => { state.documents.set( documentUrl, readExternalDocument(documentUrl, state.externalRefs).then((document) => - yaml.load(Buffer.isBuffer(document) ? document.toString('utf8') : String(document)) + yaml.load(Buffer.isBuffer(document) ? document.toString('utf8') : String(document), { + schema: cloudformationSchema, + }) ) ); } diff --git a/lib/utils/fs/parse.js b/lib/utils/fs/parse.js index 8dc98cd571..f6d635abfd 100644 --- a/lib/utils/fs/parse.js +++ b/lib/utils/fs/parse.js @@ -4,34 +4,12 @@ const jc = require('json-cycle'); const yaml = require('js-yaml'); const cloudformationSchema = require('../serverless-utils/cloudformation-schema'); -const loadYaml = (contents, options) => { - let data; - let error; - try { - data = yaml.load(contents.toString(), options || {}); - } catch (exception) { - error = exception; - } - return { data, error }; -}; - function parse(filePath, contents) { // Auto-parse JSON if (filePath.endsWith('.json') || filePath.endsWith('.tfstate')) { return jc.parse(contents); } else if (filePath.endsWith('.yml') || filePath.endsWith('.yaml')) { - const options = { - filename: filePath, - }; - let result = loadYaml(contents.toString(), options); - if (result.error && result.error.name === 'YAMLException') { - options.schema = cloudformationSchema; - result = loadYaml(contents.toString(), options); - } - if (result.error) { - throw result.error; - } - return result.data; + return yaml.load(contents.toString(), { filename: filePath, schema: cloudformationSchema }); } return contents.toString().trim(); } diff --git a/lib/utils/serverless-utils/README.md b/lib/utils/serverless-utils/README.md index b57d781e52..8b2ca39337 100644 --- a/lib/utils/serverless-utils/README.md +++ b/lib/utils/serverless-utils/README.md @@ -26,6 +26,10 @@ Source of truth: Notes: +- `cloudformation-schema.js` intentionally diverges from upstream: it removes + implicit YAML timestamp resolution and re-registers `!!timestamp` as an + explicit type (https://github.com/oss-serverless/osls/issues/438). Preserve + this when re-syncing. - `config.js` is a locally owned fork. It intentionally keeps the synchronous `get('frameworkId')` and `get('meta.created_at')` lookups that Bref v2/v3 use for best-effort telemetry if a future compatibility shim routes diff --git a/lib/utils/serverless-utils/cloudformation-schema.js b/lib/utils/serverless-utils/cloudformation-schema.js index ac02b47dd8..b5afb71421 100644 --- a/lib/utils/serverless-utils/cloudformation-schema.js +++ b/lib/utils/serverless-utils/cloudformation-schema.js @@ -43,7 +43,15 @@ const createSchema = () => { const types = functionNames.flatMap((functionName) => ['mapping', 'scalar', 'sequence'].map((kind) => yamlType(functionName, kind)) ); - return yaml.DEFAULT_SCHEMA.extend(types); + // Drop implicit timestamps so date-shaped plain scalars (e.g. an IAM policy + // `Version: 2012-10-17`) stay strings; an explicit `!!timestamp` tag still constructs a Date + const implicitTypes = yaml.DEFAULT_SCHEMA.implicit.filter( + (type) => type.tag !== 'tag:yaml.org,2002:timestamp' + ); + return new yaml.Schema({ + implicit: implicitTypes, + explicit: [...yaml.DEFAULT_SCHEMA.explicit, yaml.types.timestamp], + }).extend(types); }; module.exports = createSchema(); diff --git a/test/unit/lib/classes/yaml-parser.test.js b/test/unit/lib/classes/yaml-parser.test.js index 99e1ad3903..488fd47792 100644 --- a/test/unit/lib/classes/yaml-parser.test.js +++ b/test/unit/lib/classes/yaml-parser.test.js @@ -108,6 +108,23 @@ describe('YamlParser', () => { .to.equal('bar'); }); + it('should parse date-shaped values and shorthand tags in referenced files', () => { + const tmpDirPath = getTmpDirPath(); + + serverless.utils.writeFileSync( + path.join(tmpDirPath, 'ref.yml'), + 'date: 2012-10-17\nref: !Ref Topic\n' + ); + + serverless.utils.writeFileSync(path.join(tmpDirPath, 'test.yml'), { + main: { $ref: './ref.yml' }, + }); + + return expect(serverless.yamlParser.parse(path.join(tmpDirPath, 'test.yml'))) + .to.eventually.have.property('main') + .to.deep.equal({ date: '2012-10-17', ref: { Ref: 'Topic' } }); + }); + it('should leave same-document refs in the root file untouched', async () => { const tmpFilePath = getTmpFilePath('same-document.yml'); diff --git a/test/unit/lib/configuration/read.test.js b/test/unit/lib/configuration/read.test.js index 8c72450a74..755996073c 100644 --- a/test/unit/lib/configuration/read.test.js +++ b/test/unit/lib/configuration/read.test.js @@ -46,6 +46,35 @@ describe('test/unit/lib/configuration/read.test.js', () => { }); }); + it('should preserve date-shaped YAML values as strings', async () => { + configurationPath = 'serverless.yml'; + await fsp.writeFile( + configurationPath, + [ + 'service: test-date-strings', + 'provider:', + ' name: aws', + ' unquotedDate: 2020-12-12', + " quotedDate: '2020-12-12'", + ' explicitlyTaggedDate: !!str 2020-12-12', + ' unquotedDateTime: 2020-12-12T00:00:00Z', + ' spacedDateTime: 2020-12-12 00:00:00', + '', + ].join('\n') + ); + expect(await readConfiguration(configurationPath)).to.deep.equal({ + service: 'test-date-strings', + provider: { + name: 'aws', + unquotedDate: '2020-12-12', + quotedDate: '2020-12-12', + explicitlyTaggedDate: '2020-12-12', + unquotedDateTime: '2020-12-12T00:00:00Z', + spacedDateTime: '2020-12-12 00:00:00', + }, + }); + }); + it('should read "serverless.json"', async () => { configurationPath = 'serverless.json'; const configuration = { diff --git a/test/unit/lib/configuration/variables/sources/file.test.js b/test/unit/lib/configuration/variables/sources/file.test.js index bca3920010..2e15768101 100644 --- a/test/unit/lib/configuration/variables/sources/file.test.js +++ b/test/unit/lib/configuration/variables/sources/file.test.js @@ -16,6 +16,7 @@ describe('test/unit/lib/configuration/variables/sources/file.test.js', () => { yaml: '${file(file.yaml)}', yml: '${file(file.yml)}', json: '${file(file.json)}', + dateString: '${file(file-date.yml):date}', tfstate: '${file(file.tfstate)}', js: '${file(file.js)}', cjs: '${file(file.cjs)}', @@ -87,6 +88,9 @@ describe('test/unit/lib/configuration/variables/sources/file.test.js', () => { it('should resolve "json" file sources', () => expect(configuration.json).to.deep.equal({ result: 'json' })); + it('should resolve date-shaped values as strings', () => + expect(configuration.dateString).to.equal('2012-10-17')); + it('should resolve "tfstate" file sources', () => expect(configuration.tfstate).to.deep.equal({ result: 'tfstate' })); diff --git a/test/unit/lib/configuration/variables/sources/fixture/file-date.yml b/test/unit/lib/configuration/variables/sources/fixture/file-date.yml new file mode 100644 index 0000000000..8dc03c590d --- /dev/null +++ b/test/unit/lib/configuration/variables/sources/fixture/file-date.yml @@ -0,0 +1 @@ +date: 2012-10-17 diff --git a/test/unit/lib/utils/fs/parse.test.js b/test/unit/lib/utils/fs/parse.test.js index 7de6aec510..61b15e45c2 100644 --- a/test/unit/lib/utils/fs/parse.test.js +++ b/test/unit/lib/utils/fs/parse.test.js @@ -114,6 +114,12 @@ describe('#parse()', () => { }); }); + it('should keep date-shaped values as strings and support explicit timestamp tags', () => { + const obj = parse('anything.yml', 'date: 2012-10-17\ntagged: !!timestamp 2020-12-12'); + expect(obj.date).to.equal('2012-10-17'); + expect(obj.tagged).to.be.instanceOf(Date); + }); + it('should parse YAML without shorthand syntax', () => { const tmpFilePath = 'anything.yml'; const fileContents = 'Item:\n Fn::Join:\n - ""\n - - "arn:aws:s3::"\n - !Ref MyBucket'; diff --git a/test/unit/lib/utils/serverless-utils/cloudformation-schema.test.js b/test/unit/lib/utils/serverless-utils/cloudformation-schema.test.js new file mode 100644 index 0000000000..1b8ed2d8a8 --- /dev/null +++ b/test/unit/lib/utils/serverless-utils/cloudformation-schema.test.js @@ -0,0 +1,20 @@ +'use strict'; + +const expect = require('chai').expect; +const yaml = require('js-yaml'); +const cloudformationSchema = require('../../../../../lib/utils/serverless-utils/cloudformation-schema'); + +const load = (input) => yaml.load(input, { schema: cloudformationSchema }); + +describe('serverless-utils/cloudformation-schema', () => { + it('should keep date-shaped plain scalars and mapping keys as strings', () => { + expect(load('date: 2012-10-17').date).to.equal('2012-10-17'); + expect(load('dateTime: 2020-12-12T00:00:00Z').dateTime).to.equal('2020-12-12T00:00:00Z'); + expect(load('spaced: 2020-12-12 00:00:00').spaced).to.equal('2020-12-12 00:00:00'); + expect(load('map:\n 2012-10-17: value').map).to.deep.equal({ '2012-10-17': 'value' }); + }); + + it('should construct a Date for an explicit timestamp tag', () => { + expect(load('date: !!timestamp 2020-12-12').date).to.be.instanceOf(Date); + }); +});