@opentermsarchive/engine 14.1.0 → 15.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "14.1.0",
3
+ "version": "15.1.0",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
@@ -26,3 +26,18 @@ export function removeQueryParams(webPageDOM, paramsToRemove = []) {
26
26
  }
27
27
  }
28
28
  }
29
+
30
+ const SPACE_SEPARATORS = /\p{Zs}/gu;
31
+
32
+ export function convertSpacesToStandard(webPageDOM) {
33
+ const walker = webPageDOM.createTreeWalker(webPageDOM.body, webPageDOM.defaultView.NodeFilter.SHOW_TEXT);
34
+
35
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
36
+ const original = node.nodeValue;
37
+ const normalized = original.replace(SPACE_SEPARATORS, ' ');
38
+
39
+ if (normalized !== original) {
40
+ node.nodeValue = normalized;
41
+ }
42
+ }
43
+ }
@@ -1,7 +1,7 @@
1
1
  import { expect } from 'chai';
2
2
 
3
3
  import createWebPageDOM from './dom.js';
4
- import { removeQueryParams } from './exposedFilters.js';
4
+ import { convertSpacesToStandard, removeQueryParams } from './exposedFilters.js';
5
5
 
6
6
  describe('exposedFilters', () => {
7
7
  let webPageDOM;
@@ -265,4 +265,52 @@ fetch(trackingUrl);
265
265
  });
266
266
  });
267
267
  });
268
+
269
+ describe('#convertSpacesToStandard', () => {
270
+ describe('with Unicode space separators in text', () => {
271
+ let element;
272
+
273
+ before(() => {
274
+ element = webPageDOM.createElement('p');
275
+ element.textContent = 'a\u00A0b\u202Fc\u2009d\u3000e';
276
+ webPageDOM.body.appendChild(element);
277
+
278
+ convertSpacesToStandard(webPageDOM);
279
+ });
280
+
281
+ after(() => {
282
+ element.remove();
283
+ });
284
+
285
+ it('replaces them with a regular space', () => {
286
+ expect(element.textContent).to.equal('a b c d e');
287
+ });
288
+ });
289
+
290
+ describe('with Unicode space separators in an attribute', () => {
291
+ let element;
292
+ const className = 'label\u00A0primary';
293
+
294
+ before(() => {
295
+ element = webPageDOM.createElement('a');
296
+ element.setAttribute('class', className);
297
+ element.textContent = 'read\u00A0the\u00A0policy';
298
+ webPageDOM.body.appendChild(element);
299
+
300
+ convertSpacesToStandard(webPageDOM);
301
+ });
302
+
303
+ after(() => {
304
+ element.remove();
305
+ });
306
+
307
+ it('leaves attribute values untouched', () => {
308
+ expect(element.getAttribute('class')).to.equal(className);
309
+ });
310
+
311
+ it('replaces them in the text content', () => {
312
+ expect(element.textContent).to.equal('read the policy');
313
+ });
314
+ });
315
+ });
268
316
  });
@@ -79,7 +79,7 @@ describe('Full DOM Fetcher', function () {
79
79
  });
80
80
 
81
81
  describe('#fetch', () => {
82
- const config = { navigationTimeout: 1000, waitForElementsTimeout: 1000, language: 'en' };
82
+ const config = { navigationTimeout: 5000, waitForElementsTimeout: 5000, language: 'en' };
83
83
 
84
84
  it('waits for dynamically injected elements to appear in the DOM', async () => {
85
85
  const result = await fetch(`http://127.0.0.1:${SERVER_PORT}/dynamic`, ['.dynamic'], config);
@@ -35,7 +35,7 @@ function resetGitRepositories() {
35
35
  }
36
36
 
37
37
  describe('Archivist', function () {
38
- this.timeout(10000);
38
+ this.timeout(30000);
39
39
 
40
40
  const SERVICE_A_ID = 'service·A';
41
41
  const SERVICE_A_TYPE = 'Terms of Service';
@@ -30,6 +30,10 @@ const MULTIPLE_SOURCE_DOCUMENTS_PREFIX = 'This version was recorded after extrac
30
30
 
31
31
  export const COMMIT_MESSAGE_PREFIXES_REGEXP = new RegExp(`^(${Object.values(COMMIT_MESSAGE_PREFIXES).join('|')})`);
32
32
 
33
+ export function isTechnicalUpgrade(message) {
34
+ return message.startsWith(COMMIT_MESSAGE_PREFIXES.technicalUpgrade) || message.startsWith(COMMIT_MESSAGE_PREFIXES.deprecated_refilter);
35
+ }
36
+
33
37
  export function toPersistence(record, snapshotIdentiferTemplate) {
34
38
  const { serviceId, termsType, documentId, snapshotIds = [], mimeType, metadata } = record;
35
39
 
@@ -22,7 +22,7 @@ export default class Git {
22
22
 
23
23
  this.git = simpleGit(this.path, {
24
24
  trimmed: true,
25
- maxConcurrentProcesses: 1,
25
+ maxConcurrentProcesses: 1, // Concurrent runs on the same repository race the index and the commit-graph and can corrupt them
26
26
  });
27
27
 
28
28
  await this.git.init();
@@ -106,7 +106,8 @@ export default class Git {
106
106
 
107
107
  return commits;
108
108
  } catch (error) {
109
- if (/unknown revision or path not in the working tree|does not have any commits yet/.test(error.message)) {
109
+ // `bad object` is raised for a well-formed but absent object ID; like an unknown revision, it means "no match" rather than a hard failure
110
+ if (/unknown revision or path not in the working tree|does not have any commits yet|bad object/.test(error.message)) {
110
111
  return [];
111
112
  }
112
113
 
@@ -132,15 +133,24 @@ export default class Git {
132
133
  await fs.rm(path.join(this.path, '.git', 'objects', 'info', 'commit-graph.lock'), { force: true }); // Remove a leftover commit-graph lock from a previous `commit-graph write` that was killed mid-write (e.g. the process was terminated during a deploy or restart). The commit-graph is a disposable cache rebuilt by `writeCommitGraph`, so clearing a stale lock is safe and prevents every subsequent run from failing.
133
134
  await this.git.reset('hard');
134
135
 
135
- return this.git.clean('f', '-d');
136
+ return this.git.clean('f', '-d'); // Force-remove untracked files (`f`) and untracked directories (`-d`)
136
137
  }
137
138
 
138
139
  getFullHash(shortHash) {
139
- return this.git.show([ shortHash, '--pretty=%H', '-s' ]);
140
+ return this.git.show([
141
+ shortHash,
142
+ '--pretty=%H', // Print the full 40-character commit hash
143
+ '-s', // Suppress the diff output, only the formatted hash is wanted
144
+ ]);
140
145
  }
141
146
 
142
147
  restore(path, commit) {
143
- return this.git.raw([ 'restore', '-s', commit, '--', path ]);
148
+ return this.git.raw([
149
+ 'restore',
150
+ '-s', commit, // Take the file contents from this specific commit rather than from the index
151
+ '--', // Everything after is a pathspec, not a revision or option
152
+ path,
153
+ ]);
144
154
  }
145
155
 
146
156
  async destroyHistory() {
@@ -154,14 +164,86 @@ export default class Git {
154
164
  }
155
165
 
156
166
  async listFiles(path) {
157
- return (await this.git.raw([ 'ls-files', path ])).split('\n');
167
+ return (await this.git.raw([ 'ls-files', '--', path ])).split('\n'); // Everything after "--" is a pathspec, not a revision or option
158
168
  }
159
169
 
160
170
  async writeCommitGraph() {
161
- await this.git.raw([ 'commit-graph', 'write', '--reachable', '--changed-paths' ]);
171
+ await this.git.raw([
172
+ 'commit-graph',
173
+ 'write',
174
+ '--reachable', // Cover every commit reachable from the refs, so the whole history is indexed
175
+ '--changed-paths', // Also store the changed-path Bloom filters that speed up path-limited log/diff
176
+ ]);
162
177
  }
163
178
 
164
179
  async updateCommitGraph() {
165
- await this.git.raw([ 'commit-graph', 'write', '--reachable', '--changed-paths', '--append' ]);
180
+ await this.git.raw([
181
+ 'commit-graph',
182
+ 'write',
183
+ '--reachable',
184
+ '--changed-paths',
185
+ '--append', // Extend the existing commit-graph instead of rewriting it in full
186
+ ]);
187
+ }
188
+
189
+ async listPathRevisions(pathFilter) {
190
+ let output;
191
+
192
+ try {
193
+ // Ordering and technical-upgrade filtering are done by the caller in memory, so `--author-date-order`/`--grep`/`--name-only` are deliberately omitted to keep this walk lean
194
+ output = await this.git.raw([
195
+ 'log',
196
+ '--no-merges',
197
+ '--format=%H%x09%at%x09%s', // Tab-separated hash, author date and subject: the minimum needed to order versions and detect technical upgrades, with no diff or message body loaded
198
+ '--', // Everything after is a pathspec, never a revision or an option
199
+ pathFilter,
200
+ ]);
201
+ } catch (error) {
202
+ if (/unknown revision or path not in the working tree|does not have any commits yet/.test(error.message)) {
203
+ return [];
204
+ }
205
+
206
+ throw error;
207
+ }
208
+
209
+ if (!output) {
210
+ return [];
211
+ }
212
+
213
+ return output.trim().split('\n').filter(Boolean).map(line => {
214
+ const [ hash, timestamp, ...subjectParts ] = line.split('\t');
215
+
216
+ return { hash, timestamp: parseInt(timestamp, 10), subject: subjectParts.join('\t') };
217
+ });
218
+ }
219
+
220
+ async getDiffStats(commitHash) {
221
+ const output = await this.git.raw([
222
+ 'show',
223
+ '--numstat', // Report added/deleted line counts per file as tab-separated numbers, instead of a textual diff
224
+ '--format=', // Drop the commit header so the output holds only the numstat lines
225
+ commitHash,
226
+ ]);
227
+
228
+ let additions = 0;
229
+ let deletions = 0;
230
+
231
+ for (const line of output.trim().split('\n')) {
232
+ if (!line) {
233
+ continue;
234
+ }
235
+
236
+ const [ added, deleted ] = line.split('\t');
237
+
238
+ // Binary files show '-' for additions/deletions
239
+ if (added !== '-') {
240
+ additions += parseInt(added, 10);
241
+ }
242
+ if (deleted !== '-') {
243
+ deletions += parseInt(deleted, 10);
244
+ }
245
+ }
246
+
247
+ return { additions, deletions };
166
248
  }
167
249
  }
@@ -15,6 +15,25 @@ import Git from './git.js';
15
15
 
16
16
  const fs = fsApi.promises;
17
17
 
18
+ const RECORD_ID_REGEXP = /^[0-9a-f]{7,40}$/i; // Git commit SHA 7 (abbreviated) to 40 (full) hexadecimal characters. Prevent value such as `--output=…` to be parsed as a command-line option
19
+
20
+ const CONTROL_CHARACTERS_REGEXP = /\p{Cc}/u; // Matches any Unicode "control" character: the C0 range (U+0000 to U+001F), DEL (U+007F) and the C1 range (U+0080 to U+009F), i.e. 65 non-printable characters including NUL. The `u` flag is required for the `\p{...}` property escape to be recognised, otherwise the pattern would match the literal text `p{Cc}`. Legitimate service IDs, terms types and document IDs never contain these, and NUL in particular can truncate a value once it reaches git or the filesystem, so any segment holding one is rejected.
21
+
22
+ // Keeps hostile values from reaching git, where a pathspec that resolves outside the repository (such as `../foo/*`) aborts with an error that exposes the repository location.
23
+ function isPlainPathSegment(segment) {
24
+ return segment.length > 0
25
+ && segment !== '.'
26
+ && segment !== '..'
27
+ && !segment.includes('/')
28
+ && !segment.includes('\\')
29
+ && !CONTROL_CHARACTERS_REGEXP.test(segment);
30
+ }
31
+
32
+ function canMatchRecordFilePath(...pathSegments) {
33
+ // A non-string segment means "not provided" (`undefined`, or `false` for an absent document ID) and constrains nothing
34
+ return pathSegments.every(segment => typeof segment !== 'string' || isPlainPathSegment(segment));
35
+ }
36
+
18
37
  export default class GitRepository extends RepositoryInterface {
19
38
  constructor({ path, author, publish, snapshotIdentiferTemplate }) {
20
39
  super();
@@ -64,6 +83,10 @@ export default class GitRepository extends RepositoryInterface {
64
83
  }
65
84
 
66
85
  async findLatest(serviceId, termsType, documentId) {
86
+ if (!canMatchRecordFilePath(serviceId, termsType, documentId)) {
87
+ return null;
88
+ }
89
+
67
90
  const matchingFilesPaths = await this.git.listFiles(DataMapper.generateFilePath(serviceId, termsType, documentId));
68
91
 
69
92
  if (!matchingFilesPaths.length) {
@@ -76,35 +99,95 @@ export default class GitRepository extends RepositoryInterface {
76
99
  }
77
100
 
78
101
  async findByDate(serviceId, termsType, date, documentId) {
102
+ if (!canMatchRecordFilePath(serviceId, termsType, documentId)) {
103
+ return null;
104
+ }
105
+
79
106
  const filePath = DataMapper.generateFilePath(serviceId, termsType, documentId);
80
- const commit = await this.git.getCommit([ `--until=${date?.toISOString()}`, filePath ]);
107
+ const commit = await this.git.getCommit([ `--until=${date?.toISOString()}`, '--', filePath ]);
81
108
 
82
109
  return this.#toDomain(commit);
83
110
  }
84
111
 
85
112
  async findById(recordId) {
86
- const commit = await this.git.getCommit([recordId]);
113
+ if (!RECORD_ID_REGEXP.test(recordId)) {
114
+ return null;
115
+ }
116
+
117
+ const commit = await this.git.getCommit([ '--end-of-options', recordId ]); // `--end-of-options` forces git to treat `recordId` as a revision, never as an option: a second line of defence that keeps the lookup safe from argument injection even if the format guard above is ever relaxed
87
118
 
88
119
  return this.#toDomain(commit);
89
120
  }
90
121
 
122
+ async findMetadataById(recordId) {
123
+ if (!RECORD_ID_REGEXP.test(recordId)) {
124
+ return null;
125
+ }
126
+
127
+ const commit = await this.git.getCommit([ '--end-of-options', recordId ]); // `--end-of-options` forces git to treat `recordId` as a revision, never as an option: a second line of defence that keeps the lookup safe from argument injection even if the format guard above is ever relaxed
128
+
129
+ return this.#toDomain(commit, { deferContentLoading: true });
130
+ }
131
+
91
132
  async findAll({ limit, offset, includeTechnicalUpgrades = true } = {}) {
92
133
  return Promise.all((await this.#getCommits({ limit, offset, includeTechnicalUpgrades })).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
93
134
  }
94
135
 
136
+ async findByServiceAndTermsType(serviceId, termsType, { limit, offset, includeTechnicalUpgrades = true } = {}) {
137
+ if (!canMatchRecordFilePath(serviceId, termsType)) {
138
+ return [];
139
+ }
140
+
141
+ const pathPattern = DataMapper.generateFilePath(serviceId, termsType);
142
+
143
+ return Promise.all((await this.#getCommits({ pathFilter: pathPattern, limit, offset, includeTechnicalUpgrades })).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
144
+ }
145
+
95
146
  async findByService(serviceId, { limit, offset, includeTechnicalUpgrades = true } = {}) {
147
+ if (!canMatchRecordFilePath(serviceId)) {
148
+ return [];
149
+ }
150
+
96
151
  const pathPattern = DataMapper.generateFilePath(serviceId);
97
152
 
98
153
  return Promise.all((await this.#getCommits({ pathFilter: pathPattern, limit, offset, includeTechnicalUpgrades })).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
99
154
  }
100
155
 
101
- async findByServiceAndTermsType(serviceId, termsType, { limit, offset, includeTechnicalUpgrades = true } = {}) {
156
+ async getNavigationIds(serviceId, termsType, versionId, { includeTechnicalUpgrades = true } = {}) {
157
+ if (!canMatchRecordFilePath(serviceId, termsType)) {
158
+ return { first: null, prev: null, next: null, last: null };
159
+ }
160
+
102
161
  const pathPattern = DataMapper.generateFilePath(serviceId, termsType);
162
+ let revisions = await this.git.listPathRevisions(pathPattern); // single lean walk of the terms history
103
163
 
104
- return Promise.all((await this.#getCommits({ pathFilter: pathPattern, limit, offset, includeTechnicalUpgrades })).map(commit => this.#toDomain(commit, { deferContentLoading: true })));
164
+ if (!includeTechnicalUpgrades) {
165
+ revisions = revisions.filter(revision => !DataMapper.isTechnicalUpgrade(revision.subject));
166
+ }
167
+
168
+ // Deterministic total order: most recent first, commit SHA as a stable tiebreaker for versions sharing the same fetch date (git stores second precision).
169
+ // prev/next are then adjacent entries in this single order, so navigation always round-trips, unlike the previous chronological/topological mix.
170
+ revisions.sort((a, b) => b.timestamp - a.timestamp || (a.hash < b.hash ? -1 : 1));
171
+
172
+ const index = revisions.findIndex(revision => revision.hash === versionId);
173
+
174
+ if (index === -1) {
175
+ return { first: null, prev: null, next: null, last: null };
176
+ }
177
+
178
+ return {
179
+ last: revisions[0].hash,
180
+ first: revisions[revisions.length - 1].hash,
181
+ next: index > 0 ? revisions[index - 1].hash : null,
182
+ prev: index < revisions.length - 1 ? revisions[index + 1].hash : null,
183
+ };
105
184
  }
106
185
 
107
186
  async count(serviceId, termsType) {
187
+ if (!canMatchRecordFilePath(serviceId, termsType)) {
188
+ return 0;
189
+ }
190
+
108
191
  const grepOptions = Object.values(DataMapper.COMMIT_MESSAGE_PREFIXES).map(prefix => `--grep=${prefix}`);
109
192
  const pathOptions = [];
110
193
 
@@ -158,6 +241,10 @@ export default class GitRepository extends RepositoryInterface {
158
241
  record.content = pdfBuffer;
159
242
  }
160
243
 
244
+ getDiffStats(recordId) {
245
+ return this.git.getDiffStats(recordId);
246
+ }
247
+
161
248
  async #getCommits({ pathFilter, reverse = false, limit, offset, includeTechnicalUpgrades = true } = {}) {
162
249
  const prefixes = includeTechnicalUpgrades
163
250
  ? DataMapper.COMMIT_MESSAGE_PREFIXES