@contrast/rewriter 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright: 2022 Contrast Security, Inc
1
+ Copyright: 2024 Contrast Security, Inc
2
2
  Contact: support@contrastsecurity.com
3
3
  License: Commercial
4
4
 
package/README.md CHANGED
@@ -2,32 +2,5 @@
2
2
 
3
3
  Rewrite javascript code with custom rewrite transforms.
4
4
 
5
- For example, Assess will register transforms for `+` -> `contrast_add()` so that it can perform propagation
6
- via instrumentation of `contrast_add()`.
7
-
8
-
9
- #### Example Service Usage
10
-
11
- ```typescript
12
- const { Rewriter } = require('.');
13
-
14
- const rewriter = new Rewriter({ logger });
15
-
16
- rewriter.addTransforms({
17
- BinaryExpression(path) {
18
- const method = methodLookups[path.node.operator];
19
- if (method) {
20
- path.replaceWith(
21
- t.callExpression(
22
- expression('ContrastMethods.%%method%%')({ method }), [
23
- path.node.left,
24
- path.node.right
25
- ]
26
- )
27
- );
28
- }
29
- }
30
- });
31
-
32
- const result = rewriter.rewrite('function add(x, y) { return x + y; }');
33
- ```
5
+ For example, Assess will register transforms for `+` -> `ContrastMethods.add()`
6
+ so that it can perform propagation via instrumentation of `ContrastMethods.add()`.
package/lib/cache.js ADDED
@@ -0,0 +1,263 @@
1
+ /*
2
+ * Copyright: 2024 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+ // @ts-check
16
+ 'use strict';
17
+
18
+ const fs = require('node:fs');
19
+ const fsPromises = require('node:fs/promises');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ /**
24
+ * Returns the modification time of a file as a number.
25
+ * @param {string} filename
26
+ * @returns {Promise<number>}
27
+ */
28
+ const mtime = async (filename) => +(await fsPromises.stat(filename)).mtime;
29
+
30
+ /**
31
+ * Returns the modification time of a file as a number.
32
+ * @param {string} filename
33
+ * @returns {number}
34
+ */
35
+ const mtimeSync = (filename) => +fs.statSync(filename).mtime;
36
+
37
+ module.exports.Cache = class Cache {
38
+ /**
39
+ * @param {import('.').Core} core
40
+ */
41
+ constructor(core) {
42
+ this.config = core.config;
43
+ this.logger = core.logger.child({ name: 'contrast:rewriter:cache' });
44
+ /** @type {Set<import('.').Mode>} */
45
+ this.modes = new Set();
46
+ this.appDirRegex = new RegExp(`^${core.appInfo.app_dir.replace(/\\/g, '\\\\')}`);
47
+ this.cacheDir = path.join(
48
+ core.config.agent.node.rewrite.cache.path,
49
+ core.appInfo.name.replace('/', '_'),
50
+ core.agentVersion,
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Sets the rewriter to 'assess' or 'protect' mode, enabling different
56
+ * transforms.
57
+ * @param {import('.').Mode} mode
58
+ */
59
+ install(mode) {
60
+ this.modes.add(mode);
61
+ }
62
+
63
+ /**
64
+ * Returns the filename of a cached rewrite result. Paths within the `app_dir`
65
+ * directory are nested under the `_` directory to prevent potential
66
+ * collisions with absolute paths.
67
+ * /path/to/app/node_modules/mod/index.js
68
+ * -> '.contrast/app_name/5.1.2/assess/_/node_modules/mod/index.js
69
+ * /somewhere/else/index.js
70
+ * -> .contrast/app_name/5.1.2/assess/somewhere/else/index.js
71
+ * @param {string} filename
72
+ * @returns {string}
73
+ */
74
+ getCachedFilename(filename) {
75
+ filename = filename.replace(this.appDirRegex, '_');
76
+
77
+ if (os.platform() === 'win32') {
78
+ filename = filename.replace(/^([A-Za-z]):/, '$1_');
79
+ }
80
+
81
+ return path.join(
82
+ this.cacheDir,
83
+ this.modes.has('assess') ? 'assess' : 'protect',
84
+ filename,
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Looks up and returns the string content of a previously rewritten file
90
+ * asynchronously.
91
+ * @param {string} filename
92
+ * @returns {Promise<string | undefined>}
93
+ */
94
+ async read(filename) {
95
+ const filenameCached = this.getCachedFilename(filename);
96
+
97
+ try {
98
+ const [time, timeCached] = await Promise.all([mtime(filename), mtime(filenameCached)]);
99
+ if (time > timeCached) {
100
+ this.logger.trace(
101
+ {
102
+ filename,
103
+ filenameCached,
104
+ mtime: time,
105
+ mtimeCached: timeCached,
106
+ },
107
+ 'Cache stale, falling back to compiling.'
108
+ );
109
+
110
+ return undefined;
111
+ }
112
+
113
+ this.logger.trace(
114
+ {
115
+ filename,
116
+ filenameCached,
117
+ mtime: time,
118
+ mtimeCached: timeCached,
119
+ },
120
+ 'Cache current.'
121
+ );
122
+
123
+ return fsPromises.readFile(filenameCached, 'utf8');
124
+ } catch (err) {
125
+ // @ts-expect-error ts treats errors poorly.
126
+ if (err.code !== 'ENOENT') {
127
+ this.logger.error(
128
+ { err, filename, filenameCached },
129
+ 'An unexpected error occurred, falling back to compiling.'
130
+ );
131
+ } else {
132
+ this.logger.trace(
133
+ { filename, filenameCached },
134
+ 'Cache miss, falling back to compiling.',
135
+ );
136
+ }
137
+
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Looks up and returns the source map for a previously rewritten file.
144
+ * @param {string} filename
145
+ * @returns {Promise<string | undefined>}
146
+ */
147
+ async readMap(filename) {
148
+ const filenameCached = this.getCachedFilename(filename);
149
+ const sourceMap = `${filenameCached}.map`;
150
+
151
+ try {
152
+ return fsPromises.readFile(sourceMap, 'utf8');
153
+ } catch (err) {
154
+ // @ts-expect-error ts treats errors poorly.
155
+ if (err.code !== 'ENOENT') {
156
+ this.logger.warn(
157
+ { err, filename, filenameCached, sourceMap },
158
+ 'An unexpected error occurred finding source map.'
159
+ );
160
+ }
161
+ return undefined;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Looks up and returns the string content of a previously rewritten file
167
+ * synchronously. Used when we need to block on cache lookups.
168
+ * @param {string} filename
169
+ * @returns {string | undefined}
170
+ */
171
+ readSync(filename) {
172
+ const filenameCached = this.getCachedFilename(filename);
173
+
174
+ try {
175
+ const time = mtimeSync(filename);
176
+ const timeCached = mtimeSync(filenameCached);
177
+ if (time > timeCached) {
178
+ this.logger.trace(
179
+ {
180
+ filename,
181
+ filenameCached,
182
+ mtime: time,
183
+ mtimeCached: timeCached,
184
+ },
185
+ 'Cache stale, falling back to compiling.'
186
+ );
187
+
188
+ return undefined;
189
+ }
190
+
191
+ this.logger.trace(
192
+ {
193
+ filename,
194
+ filenameCached,
195
+ mtime: time,
196
+ mtimeCached: timeCached,
197
+ },
198
+ 'Cache current.'
199
+ );
200
+
201
+ return fs.readFileSync(filenameCached, 'utf8');
202
+ } catch (err) {
203
+ // @ts-expect-error ts treats errors poorly.
204
+ if (err.code !== 'ENOENT') {
205
+ this.logger.error(
206
+ { err, filename, filenameCached },
207
+ 'An unexpected error occurred, falling back to compiling.'
208
+ );
209
+ } else {
210
+ this.logger.trace(
211
+ { filename, filenameCached },
212
+ 'Cache miss, falling back to compiling.',
213
+ );
214
+ }
215
+
216
+ return undefined;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Writes a rewritten file to the cache directory. Runs asynchronously so that
222
+ * disk I/O doesn't impact startup times, regardless of whether we're in a CJS
223
+ * or ESM environment.
224
+ * @param {string} filename
225
+ * @param {import('@swc/core').Output} result
226
+ */
227
+ async write(filename, result) {
228
+ const filenameCached = this.getCachedFilename(filename);
229
+
230
+ try {
231
+ await fsPromises.mkdir(path.dirname(filenameCached), { recursive: true });
232
+
233
+ const writePromises = [
234
+ fsPromises.writeFile(filenameCached, result.code, 'utf8')
235
+ ];
236
+
237
+ if (result.map) {
238
+ writePromises.push(
239
+ fsPromises.writeFile(`${filenameCached}.map`, result.map, 'utf8')
240
+ );
241
+ }
242
+
243
+ await Promise.all(writePromises);
244
+
245
+ this.logger.trace(
246
+ {
247
+ filename,
248
+ filenameCached,
249
+ },
250
+ 'Cache entry created.'
251
+ );
252
+ } catch (err) {
253
+ this.logger.warn(
254
+ {
255
+ err,
256
+ filename,
257
+ filenameCached,
258
+ },
259
+ 'Unable to cache rewrite results.'
260
+ );
261
+ }
262
+ }
263
+ };
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright: 2022 Contrast Security, Inc
2
+ * Copyright: 2024 Contrast Security, Inc
3
3
  * Contact: support@contrastsecurity.com
4
4
  * License: Commercial
5
5
 
@@ -13,25 +13,100 @@
13
13
  * way not consistent with the End User License Agreement.
14
14
  */
15
15
  // @ts-check
16
-
17
16
  'use strict';
18
17
 
19
- const { transformSync } = require('@swc/core');
20
- const Module = require('module');
18
+ const { transform, transformSync } = require('@swc/core');
19
+ const Module = require('node:module');
20
+ const { Cache } = require('./cache');
21
21
 
22
- const rewriterPath = require.resolve('@contrast/agent-swc-plugin');
23
- const unwriterPath = require.resolve('@contrast/agent-swc-plugin-unwrite');
22
+ /**
23
+ * @typedef {Object} Core
24
+ * @prop {import('@contrast/common').AppInfo} appInfo
25
+ * @prop {string} agentVersion
26
+ * @prop {import('@contrast/config').Config} config
27
+ * @prop {import('@contrast/logger').Logger} logger
28
+ */
29
+ /**
30
+ * @typedef {'assess' | 'protect'} Mode
31
+ */
32
+ /**
33
+ * @typedef {Object} RewriteOpts
34
+ * @prop {string=} filename e.g. 'index.js'
35
+ * @prop {boolean=} isModule if true, file is parsed as an ES module instead of a CJS script
36
+ * @prop {boolean=} inject if true, injects ContrastMethods on the global object
37
+ * @prop {boolean=} wrap if true, wraps the content with a modified module wrapper IIFE
38
+ * @prop {boolean=} trim if true, removes added characters from the end of the generated code
39
+ */
24
40
 
25
41
  // @ts-expect-error `wrapper` is missing from @types/node
26
42
  const prefix = Module.wrapper[0];
27
43
  // @ts-expect-error `wrapper` is missing from @types/node
28
44
  const suffix = Module.wrapper[1].replace(/;$/, '.apply(this, arguments);');
29
45
 
30
- /** @typedef {'assess' | 'protect'} Mode */
46
+ const rewriterPath = require.resolve('@contrast/agent-swc-plugin');
47
+ const unwriterPath = require.resolve('@contrast/agent-swc-plugin-unwrite');
48
+
49
+ /**
50
+ * Wraps the source content as necessary to support rewriting.
51
+ * Wrapping must occur before rewriting since the underlying rewriter cannot
52
+ * parse certain valid statements such as `return` statements in a CJS script.
53
+ * @param {string} content
54
+ * @returns {string}
55
+ */
56
+ const wrap = (content) => {
57
+ let shebang = '';
58
+
59
+ // The shebang will be commented out since it cannot be present in a
60
+ // function body. swc doesn't include the commented shebang in the generated
61
+ // code despite including comments otherwise.
62
+ if (content.charAt(0) === '#') {
63
+ shebang = content.substring(0, content.indexOf('\n') + 1);
64
+ content = `//${content}`;
65
+ }
66
+
67
+ content = `${shebang}${prefix}${content}${suffix}`;
68
+
69
+ return content;
70
+ };
71
+
72
+ /**
73
+ * Trims extraneous characters that may have been added by the rewriter.
74
+ * Handles newline or semicolon insertion, removing the added characters if they
75
+ * were not present in the original source content.
76
+ * @param {string} content
77
+ * @param {import('@swc/core').Output} result
78
+ * @returns {import('@swc/core').Output}
79
+ */
80
+ const trim = (content, result) => {
81
+ let carriageReturn = 0;
82
+ // swc always adds a newline, so we only need to check the input
83
+ if (!content.endsWith('\n')) {
84
+ result.code = result.code.substring(0, result.code.length - 1);
85
+ } else if (content.endsWith('\r\n')) {
86
+ // if EOL is \r\n, then we need to account for that when we check the
87
+ // negative index of the last semicolon below
88
+ carriageReturn = 1;
89
+ }
90
+ const resultSemicolonIdx = result.code.lastIndexOf(';');
91
+ const contentSemicolonIdx = content.lastIndexOf(';');
92
+ if (contentSemicolonIdx === -1 || resultSemicolonIdx - result.code.length !== contentSemicolonIdx - content.length + carriageReturn) {
93
+ result.code = result.code.substring(0, resultSemicolonIdx) + result.code.substring(resultSemicolonIdx + 1, result.code.length);
94
+ }
95
+
96
+ return result;
97
+ };
31
98
 
32
- const rewriter = {
33
- /** @type {Set<Mode>} */
34
- modes: new Set(),
99
+ class Rewriter {
100
+ /**
101
+ * @param {Core} core
102
+ */
103
+ constructor(core) {
104
+ this.core = core;
105
+ this.logger = core.logger.child({ name: 'contrast:rewriter' });
106
+ /** @type {Set<Mode>} */
107
+ this.modes = new Set();
108
+ this.cache = new Cache(core);
109
+ }
35
110
 
36
111
  /**
37
112
  * Sets the rewriter to 'assess' or 'protect' mode, enabling different
@@ -39,94 +114,113 @@ const rewriter = {
39
114
  * @param {Mode} mode
40
115
  */
41
116
  install(mode) {
117
+ this.logger.trace('installing rewriter mode: %s', mode);
42
118
  this.modes.add(mode);
43
- },
119
+ this.cache.install(mode);
120
+ }
44
121
 
45
122
  /**
46
- * @param {string} content the source code
47
- * @param {object} opts
48
- * @param {string=} opts.filename e.g. 'index.js'
49
- * @param {boolean=} opts.isModule if true, file is parsed as an ES module instead of a CJS script
50
- * @param {boolean=} opts.inject if true, injects ContrastMethods on the global object
51
- * @param {boolean=} opts.wrap if true, wraps the content with a modified module wrapper IIFE
52
- * @returns {import("@swc/core").Output}
123
+ * @param {RewriteOpts} opts
124
+ * @returns {import('@swc/core').Options}
53
125
  */
54
- rewrite(content, opts = {}) {
55
- let shebang = '';
56
-
57
- if (content.charAt(0) === '#') {
58
- shebang = content.substring(0, content.indexOf('\n') + 1);
59
- // see the test output: swc doesn't include the commented shebang in the generated code despite including comments otherwise
60
- content = `//${content}`;
61
- }
62
-
63
- if (opts.wrap) {
64
- content = `${shebang}${prefix}${content}${suffix}`;
65
- }
66
-
67
- const result = transformSync(content, {
126
+ rewriteConfig(opts) {
127
+ return {
68
128
  filename: opts.filename,
69
129
  isModule: opts.isModule,
130
+ env: {
131
+ targets: {
132
+ node: process.versions.node
133
+ }
134
+ },
70
135
  jsc: {
71
- target: 'es2019', // should work for node >14
72
136
  experimental: {
73
- plugins: [
74
- [
75
- rewriterPath,
76
- {
77
- assess: this.modes.has('assess'),
78
- inject: this.modes.has('assess') && opts.inject,
79
- },
80
- ],
81
- ],
137
+ plugins: [[rewriterPath, {
138
+ assess: this.modes.has('assess'),
139
+ inject: opts.inject,
140
+ }]],
82
141
  },
83
142
  },
84
- sourceMaps: true,
85
- });
86
-
87
- if (!opts.wrap) {
88
- let carriageReturn = 0;
89
- // swc always adds a newline, so we only need to check the input
90
- if (!content.endsWith('\n')) {
91
- result.code = result.code.substring(0, result.code.length - 1);
92
- } else if (content.endsWith('\r\n')) {
93
- // if EOL is \r\n, then we need to account for that when we check the
94
- // negative index of the last semicolon below
95
- carriageReturn = 1;
96
- }
97
- const resultSemicolonIdx = result.code.lastIndexOf(';');
98
- const contentSemicolonIdx = content.lastIndexOf(';');
99
- if (contentSemicolonIdx === -1 || resultSemicolonIdx - result.code.length !== contentSemicolonIdx - content.length + carriageReturn) {
100
- result.code = result.code.substring(0, resultSemicolonIdx) + result.code.substring(resultSemicolonIdx + 1, result.code.length);
101
- }
143
+ // if we're trimming the output we're not rewriting an entire file, which
144
+ // means source maps are not relevant.
145
+ sourceMaps: !opts.trim && this.core.config.agent.node.source_maps.enable,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Rewrites the provided source code string asynchronously to be consumed by
151
+ * ESM hooks.
152
+ * @param {string} content
153
+ * @param {RewriteOpts=} opts
154
+ * @returns {Promise<import('@swc/core').Output>}
155
+ */
156
+ async rewrite(content, opts = {}) {
157
+ this.logger.trace({ opts }, 'rewriting %s', opts.filename);
158
+
159
+ if (opts.wrap) {
160
+ content = wrap(content);
161
+ }
162
+
163
+ let result = await transform(content, this.rewriteConfig(opts));
164
+
165
+ if (opts.trim) {
166
+ result = trim(content, result);
167
+ }
168
+
169
+ return result;
170
+ }
171
+
172
+ /**
173
+ * Rewrites the provided source code string synchronously to be consumed by
174
+ * CJS hooks.
175
+ * @param {string} content
176
+ * @param {RewriteOpts=} opts
177
+ * @returns {import('@swc/core').Output}
178
+ */
179
+ rewriteSync(content, opts = {}) {
180
+ this.logger.trace({ opts }, 'rewriting %s', opts.filename);
181
+
182
+ if (opts.wrap) {
183
+ content = wrap(content);
184
+ }
185
+
186
+ let result = transformSync(content, this.rewriteConfig(opts));
187
+
188
+ if (opts.trim) {
189
+ result = trim(content, result);
102
190
  }
103
191
 
104
192
  return result;
105
- },
193
+ }
106
194
 
107
195
  /**
196
+ * Removes contrast-related rewritten code from provided source code string.
108
197
  * @param {string} content
109
198
  * @returns {string}
110
199
  */
111
- unwrite(content) {
200
+ unwriteSync(content) {
112
201
  return transformSync(content, {
202
+ env: {
203
+ targets: {
204
+ node: process.versions.node
205
+ }
206
+ },
113
207
  jsc: {
114
- target: 'es2019', // should work for node >14
115
208
  experimental: {
116
209
  plugins: [[unwriterPath, {}]],
117
210
  },
118
211
  },
212
+ sourceMaps: false,
119
213
  }).code;
120
- },
121
- };
122
-
123
- /** @typedef {typeof rewriter} Rewriter */
214
+ }
215
+ }
124
216
 
125
217
  /**
126
- * @param {{ rewriter: Rewriter }} core
218
+ * @param {Core & { rewriter?: Rewriter; }} core
127
219
  * @returns {Rewriter}
128
220
  */
129
221
  module.exports = function init(core) {
130
- core.rewriter = rewriter;
131
- return rewriter;
222
+ core.rewriter = new Rewriter(core);
223
+ return core.rewriter;
132
224
  };
225
+
226
+ module.exports.Rewriter = Rewriter;
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Copyright: 2022 Contrast Security, Inc
2
+ * Copyright: 2024 Contrast Security, Inc
3
3
  * Contact: support@contrastsecurity.com
4
4
  * License: Commercial
5
5
 
@@ -19,27 +19,26 @@ const { readFileSync, existsSync } = require('fs');
19
19
  const { transfer } = require('multi-stage-sourcemap');
20
20
  const { SourceMapConsumer } = require('@contrast/synchronous-source-maps');
21
21
 
22
- module.exports = function(deps) {
22
+ module.exports = function (deps) {
23
23
  const sourceMaps = deps.rewriter.sourceMaps = {};
24
24
  const consumerCache = sourceMaps.consumerCache = {};
25
25
 
26
- sourceMaps.cacheConsumerMap = function(filename, map) {
26
+ sourceMaps.cacheConsumerMap = function (filename, map) {
27
27
  consumerCache[filename] = new SourceMapConsumer(map);
28
28
  };
29
29
 
30
30
  /**
31
31
  */
32
- sourceMaps.chain = function(filename, map) {
32
+ sourceMaps.chain = function (filename, map) {
33
33
  let ret;
34
34
 
35
35
  if (existsSync(`${filename}.map`)) {
36
36
  try {
37
37
  const existingMap = JSON.parse(readFileSync(`${filename}.map`, 'utf8'));
38
38
  ret = transfer({ fromSourceMap: map, toSourceMap: existingMap });
39
- deps.logger.trace(`Merged sourcemap from ${filename}.map`);
40
- } catch (e) {
41
- deps.logger.debug(`Unable to read ${filename}.map.js`);
42
- deps.logger.debug(`${e}`);
39
+ deps.logger.trace('Merged sourcemap from %s.map', filename);
40
+ } catch (err) {
41
+ deps.logger.debug({ err }, 'Unable to read %s.map.js', filename);
43
42
  }
44
43
  }
45
44
 
package/package.json CHANGED
@@ -1,14 +1,11 @@
1
1
  {
2
2
  "name": "@contrast/rewriter",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "A transpilation tool mainly used for instrumentation",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Contrast Security <nodejs@contrastsecurity.com> (https://www.contrastsecurity.com)",
7
- "files": [
8
- "lib/"
9
- ],
10
7
  "main": "lib/index.js",
11
- "types": "lib/index.d.ts",
8
+ "types": "types/index.d.ts",
12
9
  "engines": {
13
10
  "npm": ">=6.13.7 <7 || >= 8.3.1",
14
11
  "node": ">= 14.15.0"
@@ -17,10 +14,11 @@
17
14
  "test": "../scripts/test.sh"
18
15
  },
19
16
  "dependencies": {
20
- "@contrast/agent-swc-plugin": "^1.1.0",
21
- "@contrast/agent-swc-plugin-unwrite": "^1.1.0",
17
+ "@contrast/agent-swc-plugin": "^1.3.0",
18
+ "@contrast/agent-swc-plugin-unwrite": "^1.3.0",
19
+ "@contrast/common": "^1.16.0",
22
20
  "@contrast/synchronous-source-maps": "^1.1.3",
23
21
  "@swc/core": "1.3.39",
24
22
  "multi-stage-sourcemap": "^0.3.1"
25
23
  }
26
- }
24
+ }