diff --git a/bundled-addons/pithos/LICENSE b/bundled-addons/pithos/LICENSE new file mode 100644 index 00000000..ee6256cd --- /dev/null +++ b/bundled-addons/pithos/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json new file mode 100644 index 00000000..8c6a4c96 --- /dev/null +++ b/bundled-addons/pithos/addon.json @@ -0,0 +1,27 @@ +{ + "id": "pithos", + "name": "Pithos", + "version": "0.1.0", + "description": "Run s3d, the Sia S3 gateway, from Theseus: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.", + "author": "Silent Mode", + "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", + "main": "index.js", + "capabilities": [ + "toolbar-menu" + ], + "toolbar-menu": { + "title": "Pithos", + "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", + "items": [ + { + "id": "open", + "label": "Open Pithos" + }, + { + "id": "stop", + "label": "Stop s3d" + } + ] + }, + "updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json" +} diff --git a/bundled-addons/pithos/core/admin.js b/bundled-addons/pithos/core/admin.js new file mode 100644 index 00000000..892d547c --- /dev/null +++ b/bundled-addons/pithos/core/admin.js @@ -0,0 +1,34 @@ +// s3d's admin API (Basic auth, empty user): upload stats, flush, snapshots. + +function authHeader(password) { + return 'Basic ' + Buffer.from(':' + password).toString('base64'); +} + +async function adminFetch(cfg, method, route, { timeout = 10_000 } = {}) { + if (!cfg.adminAddress || !cfg.adminPassword) throw new Error('adminAddress and adminPassword must be set in s3d.yml'); + const ctl = new AbortController(); + const timer = timeout ? setTimeout(() => ctl.abort(), timeout) : null; + try { + const res = await fetch(`http://${cfg.adminAddress}${route}`, { + method, headers: { authorization: authHeader(cfg.adminPassword) }, signal: ctl.signal, + }); + const text = await res.text(); + if (!res.ok) throw new Error(`admin API ${route}: ${res.status} ${text.trim()}`); + return text; + } finally { + if (timer) clearTimeout(timer); + } +} + +export async function uploadStats(cfg) { + return JSON.parse(await adminFetch(cfg, 'GET', '/stats/uploads')); +} + +// Blocks until every pending object is on Sia, so no timeout. +export async function flush(cfg) { + await adminFetch(cfg, 'POST', '/objects/flush', { timeout: 0 }); +} + +export async function snapshot(cfg) { + return adminFetch(cfg, 'POST', '/snapshots', { timeout: 0 }); +} diff --git a/bundled-addons/pithos/core/binary.js b/bundled-addons/pithos/core/binary.js new file mode 100644 index 00000000..14420888 --- /dev/null +++ b/bundled-addons/pithos/core/binary.js @@ -0,0 +1,77 @@ +// Installs the pinned s3d release into Pithos's own bin folder. The digests are +// GitHub's published sha256 for each release asset; a download that does not +// match is discarded before anything is written to the bin folder. + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import zlib from 'node:zlib'; + +export const S3D_VERSION = '0.2.0'; + +const ASSETS = { + 'win32-x64': { file: 's3d_windows_amd64.zip', sha256: '239356ef55228d03aa1889c7208357b83faf66947ade2cba4f7a3893c632c473' }, + 'linux-x64': { file: 's3d_linux_amd64.zip', sha256: 'e537d68be8b6749e3e239d572c497a4f1ebc81ef5a3b70d5ec3cc9ffa6498e73' }, + 'linux-arm64': { file: 's3d_linux_arm64.zip', sha256: '76848764f09922f31489c809514cdc928b3882836d6e5b64dcac085a84b4958b' }, + 'darwin-x64': { file: 's3d_darwin_amd64.zip', sha256: 'd7fe282efab35242b001ed9040d833334034682225b4c2b6b2eda67ecca8992e' }, + 'darwin-arm64': { file: 's3d_darwin_arm64.zip', sha256: '9eb6b73cb30b5b6b54fbbc583af6f7d9e56a30c55247ac70568918a1c98a3353' }, +}; + +export function assetForPlatform() { + return ASSETS[`${process.platform}-${process.arch}`] || null; +} + +export async function installS3d(binDir, onProgress = () => {}) { + const asset = assetForPlatform(); + if (!asset) throw new Error(`no s3d release for ${process.platform}-${process.arch}`); + const url = `https://github.com/SiaFoundation/s3d/releases/download/v${S3D_VERSION}/${asset.file}`; + onProgress({ phase: 'download', url }); + const res = await fetch(url, { redirect: 'follow' }); + if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`); + const zip = Buffer.from(await res.arrayBuffer()); + const digest = crypto.createHash('sha256').update(zip).digest('hex'); + if (digest !== asset.sha256) throw new Error(`checksum mismatch for ${asset.file} (got ${digest})`); + onProgress({ phase: 'extract' }); + + const exe = process.platform === 'win32' ? 's3d.exe' : 's3d'; + const data = extractFromZip(zip, (name) => path.basename(name) === exe); + if (!data) throw new Error(`${exe} not found in ${asset.file}`); + fs.mkdirSync(binDir, { recursive: true }); + const target = path.join(binDir, exe); + const tmp = target + '.new'; + fs.writeFileSync(tmp, data, { mode: 0o755 }); + fs.renameSync(tmp, target); + onProgress({ phase: 'done', path: target }); + return target; +} + +// Minimal zip reader: walks the central directory and inflates one entry. +function extractFromZip(buf, match) { + let eocd = -1; + for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) { + if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + } + if (eocd < 0) throw new Error('not a zip file'); + const count = buf.readUInt16LE(eocd + 10); + let p = buf.readUInt32LE(eocd + 16); + for (let n = 0; n < count; n++) { + if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('corrupt zip directory'); + const method = buf.readUInt16LE(p + 10); + const csize = buf.readUInt32LE(p + 20); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const local = buf.readUInt32LE(p + 42); + const name = buf.toString('utf8', p + 46, p + 46 + nameLen); + p += 46 + nameLen + extraLen + commentLen; + if (!match(name)) continue; + const lName = buf.readUInt16LE(local + 26); + const lExtra = buf.readUInt16LE(local + 28); + const start = local + 30 + lName + lExtra; + const raw = buf.subarray(start, start + csize); + if (method === 0) return Buffer.from(raw); + if (method === 8) return zlib.inflateRawSync(raw); + throw new Error(`unsupported zip method ${method}`); + } + return null; +} diff --git a/bundled-addons/pithos/core/cli.js b/bundled-addons/pithos/core/cli.js new file mode 100644 index 00000000..0a62edd2 --- /dev/null +++ b/bundled-addons/pithos/core/cli.js @@ -0,0 +1,93 @@ +// Typed wrappers over s3d's user/key subcommands. s3d has no HTTP API for +// these; the CLI opens the same SQLite file the daemon uses (WAL mode, so it +// is safe while the daemon is running). + +import { spawn } from 'node:child_process'; + +export class CliError extends Error {} + +function check({ code, stdout, stderr }) { + if (code !== 0) throw new CliError((stderr || stdout).trim() || `s3d exited with code ${code}`); + return stdout; +} + +export function makeCli(daemon) { + return { + // Whether this s3d holds an app key. `s3d login` reports an existing + // registration before it reads any input; with stdin closed an + // unregistered instance hits EOF at the first prompt and exits. + async registration() { + const { stdout } = await new Promise((resolve, reject) => { + const child = spawn(daemon.binary, ['login'], { + cwd: daemon.cwd(), env: daemon.env(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + }); + let out = ''; + child.stdout.on('data', (d) => { out += d; }); + child.stderr.on('data', (d) => { out += d; }); + const timer = setTimeout(() => child.kill(), 15_000); + child.on('error', reject); + child.on('exit', () => { clearTimeout(timer); resolve({ stdout: out.replace(/\x1b\[[0-9;]*m/g, '') }); }); + }); + const m = stdout.match(/already registered with (\S+?)\.?\s*$/m); + return m ? { registered: true, indexerUrl: m[1] } : { registered: false }; + }, + + async version() { + const out = check(await daemon.run(['version'])); + const m = out.match(/s3d v?(\S+)/); + const commit = out.match(/Commit:\s*(\S+)/); + return { version: m ? m[1] : null, commit: commit ? commit[1] : null, raw: out.trim() }; + }, + + async listUsers() { + const out = check(await daemon.run(['users', 'list'])); + if (/^No users found/m.test(out)) return []; + return out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + }, + + async createUser(name) { + validateName(name); + check(await daemon.run(['users', 'create', name])); + }, + + async deleteUser(name) { + validateName(name); + check(await daemon.run(['users', 'delete', name])); + }, + + // [{ accessKeyId, secretKey, user }] + async listKeys(user) { + const out = check(await daemon.run(user ? ['keys', 'list', user] : ['keys', 'list'])); + if (/^No access keys found/m.test(out)) return []; + return out.split(/\r?\n/).filter(Boolean).map((line) => { + const [accessKeyId, secretKey, ...rest] = line.split('\t'); + return { accessKeyId, secretKey, user: rest.join('\t') }; + }); + }, + + async createKey(user, { accessKey, secretKey } = {}) { + validateName(user); + const args = ['keys', 'create']; + if (accessKey || secretKey) args.push('--access-key', accessKey || '', '--secret-key', secretKey || ''); + args.push(user); + const out = check(await daemon.run(args)); + const ak = out.match(/Access Key:\s*(\S+)/); + const sk = out.match(/Secret Key:\s*(\S+)/); + if (!ak || !sk) throw new CliError(`unexpected output from s3d keys create: ${out.trim()}`); + return { accessKeyId: ak[1], secretKey: sk[1], user }; + }, + + async deleteKey(accessKeyId) { + if (!/^[\x21-\x7e]{1,128}$/.test(accessKeyId)) throw new CliError('invalid access key id'); + check(await daemon.run(['keys', 'delete', accessKeyId])); + }, + }; +} + +// Users are passed as argv, never through a shell, but a leading '-' would be +// read as a flag by s3d's parser. +function validateName(name) { + if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$/.test(name)) { + throw new CliError('user names are 1-64 characters: letters, digits, . _ @ -, not starting with a symbol'); + } +} diff --git a/bundled-addons/pithos/core/config.js b/bundled-addons/pithos/core/config.js new file mode 100644 index 00000000..eef39cde --- /dev/null +++ b/bundled-addons/pithos/core/config.js @@ -0,0 +1,79 @@ +// s3d.yml read/write. Edits go through the yaml Document API so keys the GUI +// does not know about (and the operator's comments) survive a save. + +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import YAML from '../vendor/yaml/lib/index.js'; +import { defaultDataDir } from './paths.js'; + +// Fields the settings form exposes, as dotted yaml paths. Anything else in the +// file is left untouched. +export const EDITABLE = [ + 'apiAddress', 'apiHTTPSAddress', 'adminAddress', 'adminPassword', 'directory', + 'sia.diskUsageLimit', 'sia.uploadThreads', 'sia.dataShards', 'sia.parityShards', + 's3.hostBases', + 'log.stdout.enabled', 'log.stdout.level', 'log.file.enabled', 'log.file.level', +]; + +export const DEFAULTS = { + apiAddress: '127.0.0.1:8000', + adminAddress: '127.0.0.1:8001', +}; + +export function readConfig(file) { + const exists = fs.existsSync(file); + const doc = exists ? YAML.parseDocument(fs.readFileSync(file, 'utf8')) : new YAML.Document({}); + if (doc.errors.length) throw new Error(`${file}: ${doc.errors[0].message}`); + const data = doc.toJS() || {}; + return { + file, + exists, + data, + // Effective values the daemon will use, for things the GUI needs to reach it. + apiAddress: data.apiAddress || DEFAULTS.apiAddress, + adminAddress: data.adminAddress || DEFAULTS.adminAddress, + adminPassword: data.adminPassword || '', + directory: process.env.S3D_DATA_DIR || data.directory || defaultDataDir(), + }; +} + +// patch: { 'sia.uploadThreads': 8, 'apiHTTPSAddress': null, ... }; null/'' deletes the key. +export function writeConfig(file, patch) { + const exists = fs.existsSync(file); + const doc = exists ? YAML.parseDocument(fs.readFileSync(file, 'utf8')) : new YAML.Document({}); + if (doc.contents == null) doc.contents = doc.createNode({}); + for (const [key, value] of Object.entries(patch)) { + if (!EDITABLE.includes(key)) throw new Error(`not an editable setting: ${key}`); + const p = key.split('.'); + if (value === null || value === '' || (Array.isArray(value) && value.length === 0)) doc.deleteIn(p); + else doc.setIn(p, value); + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = file + '.tmp'; + fs.writeFileSync(tmp, doc.toString(), { mode: 0o600 }); + fs.renameSync(tmp, file); + return readConfig(file); +} + +// A fresh config the daemon can start with: admin API on, random password. +export function ensureConfig(file) { + if (fs.existsSync(file)) { + const cfg = readConfig(file); + if (cfg.data.adminAddress && cfg.data.adminPassword) return cfg; + // s3d refuses to start without both, and the GUI needs them for stats. + return writeConfig(file, { + adminAddress: cfg.data.adminAddress || DEFAULTS.adminAddress, + adminPassword: cfg.data.adminPassword || randomPassword(), + }); + } + return writeConfig(file, { + apiAddress: DEFAULTS.apiAddress, + adminAddress: DEFAULTS.adminAddress, + adminPassword: randomPassword(), + }); +} + +export function randomPassword() { + return crypto.randomBytes(18).toString('base64url'); +} diff --git a/bundled-addons/pithos/core/daemon.js b/bundled-addons/pithos/core/daemon.js new file mode 100644 index 00000000..6fd4235a --- /dev/null +++ b/bundled-addons/pithos/core/daemon.js @@ -0,0 +1,117 @@ +// Owns the s3d process: start/stop, a log ring buffer, and one-shot CLI runs +// (users/keys/version) against the same config file the daemon uses. + +import { spawn, execFile } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import path from 'node:path'; + +const LOG_LINES = 2000; +const ANSI = /\x1b\[[0-9;]*m/g; + +export class Daemon extends EventEmitter { + constructor({ binary, configFile }) { + super(); + this.binary = binary; + this.configFile = configFile; + this.child = null; + this.state = 'stopped'; // stopped | starting | running | stopping | crashed + this.exitCode = null; + this.startedAt = null; + this.logs = []; + this.seq = 0; + } + + env() { + // S3D_CONFIG_FILE pins every invocation to the config this GUI edits; the + // cwd is the config's folder because s3d also searches ./s3d.yml first. + return { ...process.env, S3D_CONFIG_FILE: this.configFile }; + } + + cwd() { return path.dirname(this.configFile); } + + status() { + return { + state: this.state, + pid: this.child?.pid ?? null, + exitCode: this.exitCode, + startedAt: this.startedAt, + binary: this.binary, + needsLogin: !!this.needsLogin, + configFile: this.configFile, + }; + } + + setState(state) { + this.state = state; + this.emit('status', this.status()); + } + + pushLog(stream, chunk) { + for (const raw of chunk.toString('utf8').split(/\r?\n/)) { + const line = raw.replace(ANSI, ''); + if (!line) continue; + const entry = { seq: ++this.seq, t: Date.now(), stream, line }; + // s3d logs "server started" once every listener is up. + if (this.state === 'starting' && /server started/.test(line)) this.setState('running'); + if (/No app key found/.test(line)) this.needsLogin = true; + this.logs.push(entry); + if (this.logs.length > LOG_LINES) this.logs.shift(); + this.emit('log', entry); + } + } + + logsSince(seq = 0) { return this.logs.filter((l) => l.seq > seq); } + + start() { + if (!this.binary) throw new Error('s3d binary not found'); + if (this.child) return this.status(); + this.exitCode = null; + this.needsLogin = false; + this.setState('starting'); + const child = spawn(this.binary, [], { + cwd: this.cwd(), env: this.env(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + }); + this.child = child; + this.startedAt = Date.now(); + child.stdout.on('data', (d) => this.pushLog('out', d)); + child.stderr.on('data', (d) => this.pushLog('err', d)); + child.on('error', (err) => { + this.pushLog('err', `failed to start s3d: ${err.message}`); + // A spawn failure never emits 'exit'. + if (child.pid === undefined) { this.child = null; this.setState('crashed'); } + }); + child.on('exit', (code, signal) => { + this.child = null; + this.exitCode = code; + this.pushLog('sys', `s3d exited (${signal || `code ${code}`})`); + this.setState(this.state === 'stopping' ? 'stopped' : 'crashed'); + }); + return this.status(); + } + + async stop() { + const child = this.child; + if (!child) return this.status(); + this.setState('stopping'); + const exited = new Promise((r) => child.once('exit', r)); + child.kill(); // SIGTERM on unix; TerminateProcess on Windows + const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 10_000); + await exited; + clearTimeout(timer); + return this.status(); + } + + // One-shot CLI run. Resolves { code, stdout, stderr }; never rejects on a + // non-zero exit so callers can surface s3d's own error text. + run(args, { timeout = 30_000 } = {}) { + if (!this.binary) return Promise.reject(new Error('s3d binary not found')); + return new Promise((resolve, reject) => { + execFile(this.binary, args, { + cwd: this.cwd(), env: this.env(), timeout, windowsHide: true, maxBuffer: 16 << 20, + }, (err, stdout, stderr) => { + if (err && typeof err.code !== 'number') return reject(err); + resolve({ code: err ? err.code : 0, stdout: stdout.replace(ANSI, ''), stderr: stderr.replace(ANSI, '') }); + }); + }); + } +} diff --git a/bundled-addons/pithos/core/login.js b/bundled-addons/pithos/core/login.js new file mode 100644 index 00000000..d60b198f --- /dev/null +++ b/bundled-addons/pithos/core/login.js @@ -0,0 +1,185 @@ +// Drives `s3d login` through a pseudo-terminal. s3d reads the recovery phrase +// with term.ReadPassword, which needs a real console, so plain pipes fail. +// The phrase only ever lives in this process's memory: it is written to the +// pty and never logged or emitted except the one-time display of a phrase +// s3d generated, which the user has to copy down. + +import { EventEmitter } from 'node:events'; +import { spawn } from 'node:child_process'; + +const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g; + +let ptyModule; +async function loadPty() { + if (ptyModule === undefined) { + try { ptyModule = await import('@lydell/node-pty'); } catch { ptyModule = null; } + } + return ptyModule && (ptyModule.default || ptyModule); +} + +export class LoginSession extends EventEmitter { + // registration: async () => ({ registered, indexerUrl }), used to learn the + // outcome when login ran in a console window we cannot read. + constructor(daemon, { registration } = {}) { + super(); + this.daemon = daemon; + this.registration = registration; + this.term = null; + this.buffer = ''; + this.state = 'idle'; + // idle | indexer | phrase | confirm | approve | registering | done | already | failed + this.info = {}; + } + + snapshot() { return { state: this.state, ...this.info }; } + + set(state, info = {}) { + this.state = state; + Object.assign(this.info, info); + this.emit('state', this.snapshot()); + } + + async start({ indexerUrl, phrase }) { + if (this.term) throw new Error('a login is already in progress'); + this.answers = { indexerUrl: indexerUrl || '', phrase: phrase || '' }; + this.info = {}; + const pty = await loadPty(); + if (!pty) return this.startInConsole(); + this.buffer = ''; + this.generated = null; + this.lastError = null; + const term = pty.spawn(this.daemon.binary, ['login'], { + name: 'xterm', cols: 200, rows: 50, cwd: this.daemon.cwd(), env: this.daemon.env(), + }); + this.term = term; + this.set('starting'); + // A cancelled session's pty can still flush output or exit after a new + // one started; only the current term may drive the state machine. + term.onData((d) => { + if (this.term !== term) return; + try { this.onData(d); } catch (e) { this.fail(e.message); } + }); + term.onExit(({ exitCode }) => { + if (this.term !== term) return; + this.term = null; + this.answers = null; + if (!['done', 'already'].includes(this.state)) { + this.set('failed', { error: this.lastError || `s3d login exited with code ${exitCode}` }); + } + }); + return this.snapshot(); + } + + // Fallback when the pty module is not shipped (the Theseus add-on): run + // s3d login in its own console window and check the result when it closes. + startInConsole() { + if (process.platform !== 'win32') { + throw new Error('run "s3d login" in a terminal on this machine, then reload this page'); + } + // detached gives a console app its own visible console window on Windows. + const child = spawn(this.daemon.binary, ['login'], { + cwd: this.daemon.cwd(), env: this.daemon.env(), detached: true, stdio: 'ignore', windowsHide: false, + }); + this.term = { kill: () => child.kill(), write() {} }; + const term = this.term; + this.answers = null; + this.set('terminal'); + child.on('error', (e) => { if (this.term === term) this.fail(e.message); }); + child.on('exit', async () => { + if (this.term !== term) return; + this.term = null; + try { + const r = await this.registration(); + if (r.registered) this.set('done', { indexerUrl: r.indexerUrl }); + else this.set('failed', { error: 'the login window closed before registration finished' }); + } catch (e) { this.set('failed', { error: e.message }); } + }); + return this.snapshot(); + } + + // The user confirms they stored a phrase s3d generated; we echo it back. + confirmGenerated() { + if (this.state !== 'confirm' || !this.generated) throw new Error('nothing to confirm'); + this.write(this.generated); + this.generated = null; + this.info.generatedPhrase = undefined; + this.set('approve-wait'); + } + + cancel() { + if (this.term) { try { this.term.kill(); } catch {} } + this.term = null; + this.answers = null; + this.generated = null; + this.set('idle', { error: undefined, approvalUrl: undefined, generatedPhrase: undefined }); + } + + write(s) { this.term?.write(s + '\r'); } + + onData(chunk) { + this.buffer += chunk.replace(ANSI, ''); + // Work line-by-line, but prompts have no trailing newline, so also look at + // the unterminated tail. + const text = this.buffer; + + // Checked first: s3d prints the error and re-prompts in the same chunk, + // and answering that prompt with a blank would make it generate a phrase. + const bad = text.match(/Invalid recovery phrase: (.+)$/m); + if (bad) { + // s3d would re-prompt forever; stop and let the user retype. + return this.fail(`invalid recovery phrase: ${bad[1].trim()}`); + } + if (/already registered with (\S+?)\.?\s*$/m.test(text)) { + const m = text.match(/already registered with (\S+?)\.?\s*$/m); + this.buffer = ''; + return this.set('already', { indexerUrl: m[1] }); + } + if (/Indexer URL \(default: [^)]*\):\s*$/.test(text)) { + this.buffer = ''; + this.set('indexer'); + return this.write(this.answers.indexerUrl); + } + if (/Recovery Phrase:\s*(.+)$/m.test(text) && !this.generated) { + // s3d generated a phrase because we sent a blank one. + this.generated = text.match(/Recovery Phrase:\s*(.+)$/m)[1].trim(); + } + if (/Confirm recovery phrase:\s*$/.test(text)) { + this.buffer = ''; + if (!this.generated) return this.fail('could not read the generated recovery phrase'); + return this.set('confirm', { generatedPhrase: this.generated }); + } + if (/Enter recovery phrase:\s*$/.test(text)) { + this.buffer = ''; + this.set('phrase'); + const p = this.answers.phrase; + this.answers.phrase = ''; + return this.write(p); + } + const url = text.match(/visiting the following URL:\s*(\S+)/); + if (url) { + this.buffer = text.slice(url.index + url[0].length); + return this.set('approve', { approvalUrl: url[1] }); + } + if (/Login successful/.test(text)) { + this.buffer = ''; + return this.set('done'); + } + if (/app connection was declined/.test(text)) { + this.buffer = ''; + return this.fail('the connection was declined on the indexer'); + } + const fatal = text.match(/^(failed to [^\n]+)$/m); + if (fatal) this.lastError = fatal[1].trim(); + // Keep the tail bounded; anything we care about is short. + if (this.buffer.length > 8192) this.buffer = this.buffer.slice(-2048); + } + + fail(error) { + const term = this.term; + this.term = null; + this.answers = null; + this.generated = null; + this.set('failed', { error, generatedPhrase: undefined }); + if (term) { try { term.kill(); } catch {} } + } +} diff --git a/bundled-addons/pithos/core/package.json b/bundled-addons/pithos/core/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/bundled-addons/pithos/core/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/bundled-addons/pithos/core/paths.js b/bundled-addons/pithos/core/paths.js new file mode 100644 index 00000000..f9fa995a --- /dev/null +++ b/bundled-addons/pithos/core/paths.js @@ -0,0 +1,49 @@ +// Where s3d keeps its config and data on each OS, mirroring the lookup in +// s3d's cmd/s3d/main.go (configSearchPaths / applicationDirectoryOS) so the +// GUI and a hand-run `s3d` always agree on which instance they are touching. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export function defaultDataDir() { + switch (process.platform) { + case 'win32': return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 's3d'); + case 'darwin': return path.join(os.homedir(), 'Library', 'Application Support', 's3d'); + default: return '/var/lib/s3d'; + } +} + +export function configSearchPaths() { + const paths = []; + if (process.env.S3D_DATA_DIR) paths.push(path.join(process.env.S3D_DATA_DIR, 's3d.yml')); + switch (process.platform) { + case 'win32': paths.push(path.join(defaultDataDir(), 's3d.yml')); break; + case 'darwin': paths.push(path.join(defaultDataDir(), 's3d.yml')); break; + default: paths.push('/etc/s3d/s3d.yml', '/var/lib/s3d/s3d.yml'); + } + return paths; +} + +// The config file this GUI manages: an explicit S3D_CONFIG_FILE wins, then the +// first existing search path, then the first search path (created on save). +export function resolveConfigPath(explicit) { + if (explicit) return path.resolve(explicit); + if (process.env.S3D_CONFIG_FILE) return path.resolve(process.env.S3D_CONFIG_FILE); + const candidates = configSearchPaths(); + return candidates.find((p) => fs.existsSync(p)) || candidates[0]; +} + +const EXE = process.platform === 'win32' ? 's3d.exe' : 's3d'; + +// Binary lookup: explicit path, S3D_BIN, the vendored copy next to Pithos, then PATH. +export function resolveBinary(explicit, vendorDir) { + const tries = [explicit, process.env.S3D_BIN, vendorDir && path.join(vendorDir, EXE)].filter(Boolean); + for (const p of tries) if (fs.existsSync(p)) return path.resolve(p); + for (const dir of (process.env.PATH || '').split(path.delimiter)) { + if (!dir) continue; + const p = path.join(dir, EXE); + if (fs.existsSync(p)) return p; + } + return null; +} diff --git a/bundled-addons/pithos/core/s3.js b/bundled-addons/pithos/core/s3.js new file mode 100644 index 00000000..8acaeda0 --- /dev/null +++ b/bundled-addons/pithos/core/s3.js @@ -0,0 +1,221 @@ +// A small S3 client for s3d: AWS SigV4 (path-style, us-east-1, UNSIGNED-PAYLOAD +// for bodies so uploads stream straight through) plus the handful of bucket +// and object calls the console needs. + +import crypto from 'node:crypto'; +import http from 'node:http'; +import { Readable } from 'node:stream'; + +const REGION = 'us-east-1'; +const SERVICE = 's3'; + +const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex'); +const hmac = (k, s) => crypto.createHmac('sha256', k).update(s).digest(); + +// RFC 3986 encoding as SigV4 wants it; '/' kept for object keys in paths. +function uriEncode(str, keepSlash) { + return encodeURIComponent(str) + .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()) + .replace(keepSlash ? /%2F/g : /$^/, '/'); +} + +function canonicalQuery(query) { + return Object.keys(query).sort() + .map((k) => `${uriEncode(k)}=${uriEncode(String(query[k] ?? ''))}`) + .join('&'); +} + +function amzDate(d = new Date()) { + return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); +} + +function signingKey(secret, date) { + return hmac(hmac(hmac(hmac('AWS4' + secret, date), REGION), SERVICE), 'aws4_request'); +} + +export function objectPath(bucket, key) { + return '/' + uriEncode(bucket) + (key != null ? '/' + uriEncode(key, true) : ''); +} + +export class S3Client { + // endpoint: 'http://127.0.0.1:8000' + constructor({ endpoint, accessKeyId, secretKey }) { + this.endpoint = new URL(endpoint); + this.accessKeyId = accessKeyId; + this.secretKey = secretKey; + } + + sign(method, path, query, headers, { now = amzDate(), payloadHash = 'UNSIGNED-PAYLOAD' } = {}) { + const date = now.slice(0, 8); + const h = { ...headers, host: this.endpoint.host, 'x-amz-date': now, 'x-amz-content-sha256': payloadHash }; + const names = Object.keys(h).map((k) => k.toLowerCase()).sort(); + const lower = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), String(v).trim()])); + const signed = names.join(';'); + const canonical = [method, path, canonicalQuery(query), names.map((n) => `${n}:${lower[n]}\n`).join(''), signed, payloadHash].join('\n'); + const scope = `${date}/${REGION}/${SERVICE}/aws4_request`; + const toSign = ['AWS4-HMAC-SHA256', now, scope, sha256(canonical)].join('\n'); + const sig = crypto.createHmac('sha256', signingKey(this.secretKey, date)).update(toSign).digest('hex'); + lower.authorization = `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${scope}, SignedHeaders=${signed}, Signature=${sig}`; + delete lower.host; + return lower; + } + + // Presigned GET for share links. publicEndpoint lets the link name the + // address readers will use, which may differ from the loopback the GUI uses. + presign(bucket, key, expiresSec = 3600, publicEndpoint, now = amzDate()) { + const base = publicEndpoint ? new URL(publicEndpoint) : this.endpoint; + const date = now.slice(0, 8); + const scope = `${date}/${REGION}/${SERVICE}/aws4_request`; + const path = objectPath(bucket, key); + const query = { + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-Credential': `${this.accessKeyId}/${scope}`, + 'X-Amz-Date': now, + 'X-Amz-Expires': String(Math.min(Math.max(1, expiresSec | 0), 604800)), + 'X-Amz-SignedHeaders': 'host', + }; + const canonical = ['GET', path, canonicalQuery(query), `host:${base.host}\n`, 'host', 'UNSIGNED-PAYLOAD'].join('\n'); + const toSign = ['AWS4-HMAC-SHA256', now, scope, sha256(canonical)].join('\n'); + query['X-Amz-Signature'] = crypto.createHmac('sha256', signingKey(this.secretKey, date)).update(toSign).digest('hex'); + return `${base.origin}${path}?${canonicalQuery(query)}`; + } + + // Low-level request. body: Buffer | string | Readable | undefined. + // Resolves the raw http.IncomingMessage so callers can stream it. + request(method, path, { query = {}, headers = {}, body } = {}) { + const signedHeaders = this.sign(method, path, query, headers); + const qs = canonicalQuery(query); + return new Promise((resolve, reject) => { + const req = http.request({ + protocol: this.endpoint.protocol, + hostname: this.endpoint.hostname, + port: this.endpoint.port, + method, + path: path + (qs ? '?' + qs : ''), + headers: signedHeaders, + }, resolve); + req.on('error', reject); + if (body instanceof Readable) body.pipe(req); + else req.end(body); + }); + } + + async call(method, path, opts) { + const res = await this.request(method, path, opts); + const text = await readBody(res); + if (res.statusCode >= 300) throw s3Error(res.statusCode, text); + return { status: res.statusCode, headers: res.headers, text }; + } + + async listBuckets() { + const { text } = await this.call('GET', '/'); + return blocks(text, 'Bucket').map((b) => ({ name: tag(b, 'Name'), created: tag(b, 'CreationDate') })); + } + + createBucket(bucket) { return this.call('PUT', objectPath(bucket)); } + deleteBucket(bucket) { return this.call('DELETE', objectPath(bucket)); } + + async listObjects(bucket, { prefix = '', delimiter = '/', token, max = 1000 } = {}) { + const query = { 'list-type': '2', prefix, delimiter, 'max-keys': String(max) }; + if (token) query['continuation-token'] = token; + const { text } = await this.call('GET', objectPath(bucket), { query }); + return { + folders: blocks(text, 'CommonPrefixes').map((b) => tag(b, 'Prefix')), + objects: blocks(text, 'Contents').map((b) => ({ + key: tag(b, 'Key'), size: Number(tag(b, 'Size')), modified: tag(b, 'LastModified'), etag: tag(b, 'ETag'), + })), + truncated: tag(text, 'IsTruncated') === 'true', + nextToken: tag(text, 'NextContinuationToken'), + }; + } + + async deleteObject(bucket, key) { return this.call('DELETE', objectPath(bucket, key)); } + + // Deletes every key under prefix. Returns the number deleted. + async deletePrefix(bucket, prefix) { + let n = 0; + let token; + do { + const page = await this.listObjects(bucket, { prefix, delimiter: '', token }); + for (const o of page.objects) { await this.deleteObject(bucket, o.key); n++; } + token = page.truncated ? page.nextToken : null; + } while (token); + return n; + } + + putObject(bucket, key, body, { contentType, contentLength } = {}) { + const headers = {}; + if (contentType) headers['content-type'] = contentType; + if (contentLength != null) headers['content-length'] = String(contentLength); + return this.call('PUT', objectPath(bucket, key), { headers, body }); + } + + getObject(bucket, key, { range } = {}) { + return this.request('GET', objectPath(bucket, key), { headers: range ? { range } : {} }); + } + + async getPolicy(bucket) { + const res = await this.request('GET', objectPath(bucket), { query: { policy: '' } }); + const text = await readBody(res); + if (res.statusCode === 404) return null; + if (res.statusCode >= 300) throw s3Error(res.statusCode, text); + return JSON.parse(text); + } + + putPolicy(bucket, policy) { + const body = JSON.stringify(policy); + return this.call('PUT', objectPath(bucket), { query: { policy: '' }, body, headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } }); + } + + deletePolicy(bucket) { return this.call('DELETE', objectPath(bucket), { query: { policy: '' } }); } +} + +// The only policy shape s3d accepts: anonymous read of objects and listing. +export function publicReadPolicy(bucket, { list = false } = {}) { + const statements = [{ + Effect: 'Allow', Principal: '*', Action: ['s3:GetObject'], Resource: [`arn:aws:s3:::${bucket}/*`], + }]; + if (list) statements.push({ Effect: 'Allow', Principal: '*', Action: ['s3:ListBucket'], Resource: [`arn:aws:s3:::${bucket}`] }); + return { Version: '2012-10-17', Statement: statements }; +} + +export function readBody(res) { + return new Promise((resolve, reject) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + res.on('error', reject); + }); +} + +export class S3Error extends Error { + constructor(status, code, message) { super(message || code); this.status = status; this.code = code; } +} + +function s3Error(status, text) { + return new S3Error(status, tag(text, 'Code') || `HTTP ${status}`, tag(text, 'Message') || text.slice(0, 200) || `HTTP ${status}`); +} + +// S3 responses are flat enough that tag extraction is all we need. +function blocks(xml, name) { + const re = new RegExp(`<${name}>([\\s\\S]*?)`, 'g'); + const out = []; + let m; + while ((m = re.exec(xml))) out.push(m[1]); + return out; +} + +function tag(xml, name) { + const m = xml.match(new RegExp(`<${name}>([\\s\\S]*?)`)); + return m ? unescapeXml(m[1]) : null; +} + +function unescapeXml(s) { + return s.replace(/&(lt|gt|amp|quot|apos|#\d+|#x[0-9a-f]+);/gi, (_, e) => { + switch (e.toLowerCase()) { + case 'lt': return '<'; case 'gt': return '>'; case 'amp': return '&'; + case 'quot': return '"'; case 'apos': return "'"; + default: return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)); + } + }); +} diff --git a/bundled-addons/pithos/core/server.js b/bundled-addons/pithos/core/server.js new file mode 100644 index 00000000..261ec7bb --- /dev/null +++ b/bundled-addons/pithos/core/server.js @@ -0,0 +1,474 @@ +// The Pithos control server: one JSON API + the static UI. Every host (web +// console, desktop window, Theseus tab) runs exactly this and points a view +// at it, so features are written once. +// +// Auth: a session secret. Local hosts pass it in the first URL (?t=...); +// the web console can instead accept a password. Either way it is exchanged +// for an HttpOnly SameSite=Strict cookie, and mutating requests also need the +// x-pithos header so a cross-site form can never drive the API. + +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +import { resolveConfigPath, resolveBinary } from './paths.js'; +import { readConfig, writeConfig, ensureConfig, EDITABLE } from './config.js'; +import { Daemon } from './daemon.js'; +import { makeCli, CliError } from './cli.js'; +import { LoginSession } from './login.js'; +import { S3Client, S3Error, publicReadPolicy, readBody } from './s3.js'; +import * as admin from './admin.js'; +import { installS3d, S3D_VERSION, assetForPlatform } from './binary.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const UI_DIR = path.join(HERE, '..', 'ui'); +const MIME = { + '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', + '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', + '.woff2': 'font/woff2', +}; + +class HttpError extends Error { + constructor(status, message) { super(message); this.status = status; } +} + +export async function createPithos(opts = {}) { + const { + host = '127.0.0.1', + port = 0, + configFile: configOpt, + binary: binaryOpt, + binDir, // where installS3d puts the pinned release + password, // web console: optional login password + allowedHosts = [], // extra Host header values (reverse proxy names) + hostName = 'web', // 'web' | 'desktop' | 'theseus' — UI tweaks only + openExternal, // (url) => void, for hosts that can open the system browser + } = opts; + + const configFile = resolveConfigPath(configOpt); + const daemon = new Daemon({ configFile, binary: resolveBinary(binaryOpt, binDir) }); + const cli = makeCli(daemon); + const login = new LoginSession(daemon, { registration: () => cli.registration() }); + const secret = crypto.randomBytes(24).toString('base64url'); + const sessions = new Set(); + const subscribers = new Set(); + let versionCache = null; + + const broadcast = (event, data) => { + const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of subscribers) res.write(msg); + }; + daemon.on('status', (s) => { + // A registration done outside Pithos (s3d login by hand) shows up on the next start. + if (s.state === 'starting' && !regCache?.registered) regCache = null; + broadcast('status', s); + }); + daemon.on('log', (l) => broadcast('log', l)); + login.on('state', (s) => { + if (s.state === 'done' || s.state === 'already') regCache = { registered: true, indexerUrl: s.indexerUrl || regCache?.indexerUrl }; + broadcast('login', s); + }); + + // Registration costs an s3d process run, so it is cached and only + // recomputed when something that could change it happens. + let regCache = null; + async function registration() { + if (!regCache && daemon.binary) regCache = await cli.registration().catch(() => null); + return regCache; + } + + function cfg() { return readConfig(configFile); } + + async function version() { + if (!daemon.binary) return null; + if (versionCache?.binary === daemon.binary) return versionCache.info; + try { + const info = await cli.version(); + versionCache = { binary: daemon.binary, info }; + return info; + } catch (e) { + return { version: null, error: e.message }; + } + } + + // An s3d we did not start (a service, or one run by hand) that answers on + // the configured admin address. + async function probeExternal(c) { + if (daemon.child || !c.adminPassword) return false; + try { await admin.uploadStats(c); return true; } catch { return false; } + } + + async function s3For(user) { + const keys = await cli.listKeys(user); + if (!keys.length) throw new HttpError(409, `user "${user}" has no access keys yet - create one first`); + const c = cfg(); + return new S3Client({ endpoint: `http://${c.apiAddress}`, accessKeyId: keys[0].accessKeyId, secretKey: keys[0].secretKey }); + } + + // ---- routing --------------------------------------------------------- + + const routes = []; + const route = (method, pattern, handler) => { + const keys = []; + const re = new RegExp('^' + pattern.replace(/:(\w+)/g, (_, k) => { keys.push(k); return '([^/]+)'; }) + '$'); + routes.push({ method, re, keys, handler }); + }; + + route('GET', '/api/status', async () => { + const c = cfg(); + const v = await version(); + return { + host: hostName, + daemon: daemon.status(), + external: await probeExternal(c), + s3d: { ...v, pinned: S3D_VERSION, installable: !!assetForPlatform(), outdated: !!(v?.version && cmpVersion(v.version, S3D_VERSION) < 0) }, + config: { file: c.file, exists: c.exists, apiAddress: c.apiAddress, adminAddress: c.adminAddress, directory: c.directory, https: c.data.apiHTTPSAddress || null }, + login: login.snapshot(), + registration: await registration(), + }; + }); + + route('POST', '/api/daemon/start', async () => { + ensureConfig(configFile); + return daemon.start(); + }); + route('POST', '/api/daemon/stop', () => daemon.stop()); + route('POST', '/api/daemon/restart', async () => { + await daemon.stop(); + ensureConfig(configFile); + return daemon.start(); + }); + route('GET', '/api/logs', (req, url) => daemon.logsSince(Number(url.searchParams.get('since') || 0))); + + route('POST', '/api/binary/install', async () => { + if (daemon.child) throw new HttpError(409, 'stop s3d before replacing its binary'); + if (!binDir) throw new HttpError(400, 'this host has no writable bin folder'); + const target = await installS3d(binDir, (p) => broadcast('install', p)); + daemon.binary = target; + versionCache = null; + regCache = null; + return { binary: target, version: await version() }; + }); + + route('GET', '/api/config', () => { + const c = cfg(); + const values = {}; + for (const k of EDITABLE) values[k] = getIn(c.data, k.split('.')) ?? null; + return { file: c.file, exists: c.exists, values }; + }); + route('PUT', '/api/config', async (req) => { + const patch = await jsonBody(req); + const c = writeConfig(configFile, patch); + if ('directory' in patch) regCache = null; + return { file: c.file, restartNeeded: !!daemon.child }; + }); + + // Login (indexer registration) + route('GET', '/api/login', () => login.snapshot()); + route('POST', '/api/login', async (req) => { + const { indexerUrl, phrase } = await jsonBody(req); + if (phrase && phrase.trim().split(/\s+/).length !== 12) throw new HttpError(400, 'a recovery phrase is 12 words'); + if (indexerUrl && !/^https?:\/\/[^\s]+$/.test(indexerUrl)) throw new HttpError(400, 'indexer URL must be http(s)'); + ensureConfig(configFile); + return login.start({ indexerUrl: indexerUrl?.trim(), phrase: phrase?.trim().toLowerCase().split(/\s+/).join(' ') }); + }); + route('POST', '/api/login/confirm', () => { login.confirmGenerated(); return login.snapshot(); }); + route('POST', '/api/login/cancel', () => { login.cancel(); return login.snapshot(); }); + route('POST', '/api/open-external', async (req) => { + const { url } = await jsonBody(req); + if (!openExternal) throw new HttpError(400, 'not supported by this host'); + if (!/^https:\/\//.test(url)) throw new HttpError(400, 'only https links'); + openExternal(url); + return { ok: true }; + }); + + // Users & keys + route('GET', '/api/users', async () => { + const [users, keys] = await Promise.all([cli.listUsers(), cli.listKeys()]); + return users.map((name) => ({ name, keys: keys.filter((k) => k.user === name).map((k) => k.accessKeyId) })); + }); + route('POST', '/api/users', async (req) => { + const { name, withKey = true } = await jsonBody(req); + await cli.createUser(name); + const key = withKey ? await cli.createKey(name) : null; + return { name, key }; + }); + route('DELETE', '/api/users/:name', async (req, url, p) => { await cli.deleteUser(p.name); return { ok: true }; }); + route('GET', '/api/keys', (req, url) => cli.listKeys(url.searchParams.get('user') || undefined)); + route('POST', '/api/keys', async (req) => { + const { user, accessKey, secretKey } = await jsonBody(req); + return cli.createKey(user, { accessKey, secretKey }); + }); + route('DELETE', '/api/keys/:id', async (req, url, p) => { await cli.deleteKey(p.id); return { ok: true }; }); + + // Upload pipeline + route('GET', '/api/stats', () => admin.uploadStats(cfg())); + route('POST', '/api/flush', async () => { await admin.flush(cfg()); return { ok: true }; }); + + // Buckets & objects, acting as one s3d user. + route('GET', '/api/s3/:user/buckets', async (req, url, p) => (await s3For(p.user)).listBuckets()); + route('POST', '/api/s3/:user/buckets', async (req, url, p) => { + const { name } = await jsonBody(req); + if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(name || '')) throw new HttpError(400, 'bucket names are 3-63 chars: lowercase letters, digits, dots, hyphens'); + await (await s3For(p.user)).createBucket(name); + return { name }; + }); + route('DELETE', '/api/s3/:user/buckets/:bucket', async (req, url, p) => { + await (await s3For(p.user)).deleteBucket(p.bucket); + return { ok: true }; + }); + route('GET', '/api/s3/:user/buckets/:bucket/objects', async (req, url, p) => { + const q = url.searchParams; + return (await s3For(p.user)).listObjects(p.bucket, { prefix: q.get('prefix') || '', token: q.get('token') || undefined }); + }); + route('PUT', '/api/s3/:user/buckets/:bucket/object', async (req, url, p) => { + const key = requireKey(url); + const len = req.headers['content-length']; + if (len == null) throw new HttpError(411, 'Content-Length required'); + if (Number(len) > 5 * 1024 ** 3) throw new HttpError(413, 'single uploads are limited to 5 GiB'); + await (await s3For(p.user)).putObject(p.bucket, key, req, { contentType: req.headers['content-type'], contentLength: len }); + return { key }; + }); + route('GET', '/api/s3/:user/buckets/:bucket/object', async (req, url, p, res) => { + const key = requireKey(url); + const up = await (await s3For(p.user)).getObject(p.bucket, key, { range: req.headers.range }); + if (up.statusCode >= 300 && up.statusCode !== 304) { + throw new HttpError(up.statusCode, (await readBody(up)).match(/(.*?)<\/Message>/)?.[1] || `HTTP ${up.statusCode}`); + } + const headers = {}; + for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'etag', 'last-modified']) if (up.headers[h]) headers[h] = up.headers[h]; + const name = key.split('/').pop() || 'download'; + headers['content-disposition'] = `${url.searchParams.get('inline') ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`; + headers['x-content-type-options'] = 'nosniff'; + // Objects are user content: never let them run script on this origin. + headers['content-security-policy'] = "sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'"; + res.writeHead(up.statusCode, headers); + up.pipe(res); + return STREAMED; + }); + route('DELETE', '/api/s3/:user/buckets/:bucket/object', async (req, url, p) => { + await (await s3For(p.user)).deleteObject(p.bucket, requireKey(url)); + return { ok: true }; + }); + route('DELETE', '/api/s3/:user/buckets/:bucket/prefix', async (req, url, p) => { + const prefix = url.searchParams.get('prefix'); + if (!prefix) throw new HttpError(400, 'prefix required'); + return { deleted: await (await s3For(p.user)).deletePrefix(p.bucket, prefix) }; + }); + route('GET', '/api/s3/:user/buckets/:bucket/presign', async (req, url, p) => { + const q = url.searchParams; + const client = await s3For(p.user); + return { url: client.presign(p.bucket, requireKey(url), Number(q.get('expires') || 3600), q.get('endpoint') || undefined) }; + }); + route('GET', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => { + const policy = await (await s3For(p.user)).getPolicy(p.bucket); + return { policy, public: describePolicy(policy) }; + }); + route('PUT', '/api/s3/:user/buckets/:bucket/policy', async (req, url, p) => { + const { mode } = await jsonBody(req); // 'private' | 'read' | 'read-list' + const client = await s3For(p.user); + if (mode === 'private') { await client.deletePolicy(p.bucket).catch((e) => { if (e.status !== 404) throw e; }); } + else if (mode === 'read' || mode === 'read-list') await client.putPolicy(p.bucket, publicReadPolicy(p.bucket, { list: mode === 'read-list' })); + else throw new HttpError(400, 'mode must be private, read or read-list'); + return { ok: true }; + }); + + // ---- server ------------------------------------------------------------ + + const STREAMED = Symbol('streamed'); + let boundPort = port; + + function hostAllowed(h) { + if (!h) return false; + const ok = [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...allowedHosts]; + return ok.includes(h.toLowerCase()); + } + + function cookieSession(req) { + const m = /(?:^|;\s*)pithos=([^;]+)/.exec(req.headers.cookie || ''); + return m && sessions.has(m[1]) ? m[1] : null; + } + + function issueSession(res) { + const id = crypto.randomBytes(24).toString('base64url'); + sessions.add(id); + res.setHeader('set-cookie', `pithos=${id}; HttpOnly; SameSite=Strict; Path=/`); + return id; + } + + const server = http.createServer(async (req, res) => { + // DNS-rebinding guard: a page on evil.example resolving to 127.0.0.1 still + // sends its own Host header. + if (!hostAllowed(req.headers.host)) { res.writeHead(421); return res.end('misdirected request'); } + const url = new URL(req.url, `http://${req.headers.host}`); + res.setHeader('x-frame-options', 'DENY'); + res.setHeader('referrer-policy', 'no-referrer'); + + try { + // Session bootstrap: ?t= on any page swaps the secret for a cookie. + const t = url.searchParams.get('t'); + if (t && safeEqual(t, secret) && req.method === 'GET' && !url.pathname.startsWith('/api/')) { + issueSession(res); + res.writeHead(302, { location: url.pathname }); + return res.end(); + } + + if (url.pathname === '/api/session') { + if (req.method === 'GET') return sendJson(res, 200, { authenticated: !!cookieSession(req), passwordLogin: !!password, host: hostName }); + if (req.method === 'POST') { + requireCsrf(req); + const body = await jsonBody(req); + if (!password || !safeEqual(String(body.password || ''), password)) { + await new Promise((r) => setTimeout(r, 750)); + throw new HttpError(401, 'wrong password'); + } + issueSession(res); + return sendJson(res, 200, { authenticated: true }); + } + if (req.method === 'DELETE') { + const s = cookieSession(req); + if (s) sessions.delete(s); + res.setHeader('set-cookie', 'pithos=; Max-Age=0; Path=/'); + return sendJson(res, 200, { authenticated: false }); + } + } + + if (url.pathname.startsWith('/api/')) { + if (!cookieSession(req)) throw new HttpError(401, 'not signed in'); + if (req.method !== 'GET') requireCsrf(req); + + if (url.pathname === '/api/events') { + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' }); + res.write(`event: status\ndata: ${JSON.stringify(daemon.status())}\n\n`); + res.write(`event: login\ndata: ${JSON.stringify(login.snapshot())}\n\n`); + subscribers.add(res); + const ping = setInterval(() => res.write(': ping\n\n'), 25_000); + req.on('close', () => { clearInterval(ping); subscribers.delete(res); }); + return; + } + + for (const r of routes) { + if (r.method !== req.method) continue; + const m = r.re.exec(url.pathname); + if (!m) continue; + const params = Object.fromEntries(r.keys.map((k, i) => [k, decodeURIComponent(m[i + 1])])); + const out = await r.handler(req, url, params, res); + if (out === STREAMED) return; + return sendJson(res, 200, out ?? { ok: true }); + } + throw new HttpError(404, 'no such endpoint'); + } + + return serveStatic(req, res, url); + } catch (err) { + const status = err instanceof HttpError ? err.status + : err instanceof S3Error ? (err.status >= 400 && err.status < 600 ? err.status : 502) + : err instanceof CliError ? 400 + : 500; + if (status === 500) console.error('[pithos]', err); + if (res.headersSent) return res.destroy(); + sendJson(res, status, { error: err.message, code: err.code }); + } + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, resolve); + }); + boundPort = server.address().port; + const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host; + const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`; + + return { + server, + daemon, + url: baseUrl, + // Opening this URL signs the view in. + authUrl: `${baseUrl}?t=${secret}`, + async close() { + login.cancel(); + for (const s of subscribers) s.end(); + await daemon.stop(); + await new Promise((r) => server.close(r)); + }, + }; +} + +// ---- helpers ------------------------------------------------------------- + +function sendJson(res, status, body) { + const data = JSON.stringify(body); + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + res.end(data); +} + +function requireCsrf(req) { + if (req.headers['x-pithos'] !== '1') throw new HttpError(403, 'missing x-pithos header'); +} + +function requireKey(url) { + const key = url.searchParams.get('key'); + if (!key) throw new HttpError(400, 'key required'); + return key; +} + +async function jsonBody(req) { + const chunks = []; + let size = 0; + for await (const c of req) { + size += c.length; + if (size > 1 << 20) throw new HttpError(413, 'body too large'); + chunks.push(c); + } + if (!chunks.length) return {}; + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new HttpError(400, 'invalid JSON'); } +} + +function safeEqual(a, b) { + const x = Buffer.from(String(a)); + const y = Buffer.from(String(b)); + return x.length === y.length && crypto.timingSafeEqual(x, y); +} + +function getIn(obj, keys) { + return keys.reduce((o, k) => (o == null ? undefined : o[k]), obj); +} + +function cmpVersion(a, b) { + const pa = a.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0); + const pb = b.replace(/^v/, '').split(/[.-]/).map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); + return 0; +} + +function describePolicy(policy) { + if (!policy) return 'private'; + const actions = new Set(); + for (const s of [].concat(policy.Statement || [])) { + if (s.Effect !== 'Allow') continue; + if (s.Principal !== '*' && s.Principal?.AWS !== '*') continue; + for (const a of [].concat(s.Action || [])) actions.add(a); + } + if (actions.has('s3:ListBucket')) return 'read-list'; + if (actions.has('s3:GetObject')) return 'read'; + return 'custom'; +} + +function serveStatic(req, res, url) { + if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); return res.end(); } + let rel = decodeURIComponent(url.pathname); + if (rel === '/' || !path.extname(rel)) rel = '/index.html'; + const file = path.join(UI_DIR, path.normalize(rel)); + if (!file.startsWith(UI_DIR + path.sep)) { res.writeHead(403); return res.end(); } + fs.readFile(file, (err, data) => { + if (err) { res.writeHead(404); return res.end('not found'); } + res.writeHead(200, { + 'content-type': MIME[path.extname(file)] || 'application/octet-stream', + 'cache-control': 'no-cache', + 'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'", + }); + res.end(req.method === 'HEAD' ? undefined : data); + }); +} diff --git a/bundled-addons/pithos/index.js b/bundled-addons/pithos/index.js new file mode 100644 index 00000000..2d2657b5 --- /dev/null +++ b/bundled-addons/pithos/index.js @@ -0,0 +1,57 @@ +// Pithos for Theseus. The add-on runs the same control server as the web +// console and desktop app, in Theseus's main process, and opens its UI in a +// normal tab at a random loopback port. The first URL carries a one-time +// secret that the server swaps for an HttpOnly cookie. +// +// Loaded with require() by the add-on host; core/ is ESM, hence import(). + +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +let pithos = null; +let starting = null; + +module.exports = { + activate(api) { + // The pinned s3d release lands in the per-add-on data folder, never in the + // add-on folder itself (that one is replaced on update). + const dataDir = path.join(api.dataDir || path.join(api.folder, "..", "..", "extensions-data"), "pithos"); + + async function ensureServer() { + if (pithos) return pithos; + if (!starting) { + starting = (async () => { + const { createPithos } = await import(pathToFileURL(path.join(__dirname, "core", "server.js")).href); + pithos = await createPithos({ + host: "127.0.0.1", + port: 0, + hostName: "theseus", + binDir: path.join(dataDir, "bin"), + openExternal: (url) => api.openTab(url), + }); + api.log(`control server on ${pithos.url}`); + return pithos; + })().finally(() => { starting = null; }); + } + return starting; + } + + api.onMessage("menu-select", async (payload) => { + const id = payload && payload.id; + if (id === "open") { + const p = await ensureServer(); + api.openTab(p.authUrl); + } else if (id === "stop") { + if (pithos) await pithos.daemon.stop(); + } + }); + + // The host has no quit hook for add-ons; without this an s3d we started + // would outlive Theseus. + process.on("exit", () => { try { pithos && pithos.daemon.child && pithos.daemon.child.kill(); } catch {} }); + }, + + async deactivate() { + if (pithos) { const p = pithos; pithos = null; await p.close(); } + }, +}; diff --git a/bundled-addons/pithos/package.json b/bundled-addons/pithos/package.json new file mode 100644 index 00000000..53820f7f --- /dev/null +++ b/bundled-addons/pithos/package.json @@ -0,0 +1,4 @@ +{ + "type": "commonjs", + "private": true +} diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css new file mode 100644 index 00000000..eb84d11b --- /dev/null +++ b/bundled-addons/pithos/ui/app.css @@ -0,0 +1,211 @@ +:root { + --bg: #f6f7f8; + --surface: #ffffff; + --surface-2: #f0f2f4; + --border: #dfe3e7; + --text: #15191d; + --muted: #5d6873; + --accent: #1e9e72; + --accent-ink: #ffffff; + --accent-soft: #e2f5ed; + --danger: #c93a3a; + --danger-soft: #fbe9e9; + --warn: #b7791f; + --warn-soft: #fdf3e1; + --mono: ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace; + --radius: 10px; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1214; + --surface: #171b1f; + --surface-2: #1e2328; + --border: #2a3036; + --text: #e6eaee; + --muted: #8d98a3; + --accent: #3ddc97; + --accent-ink: #06231a; + --accent-soft: #123327; + --danger: #ef6b6b; + --danger-soft: #3a1c1c; + --warn: #e0a84a; + --warn-soft: #3a2c14; + color-scheme: dark; + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--bg); color: var(--text); + font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +[hidden] { display: none !important; } +a { color: var(--accent); } +h1 { font-size: 22px; margin: 0 0 4px; font-weight: 650; } +h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; } +h3 { font-size: 14px; margin: 0 0 8px; font-weight: 600; } +code, .mono { font-family: var(--mono); font-size: 12.5px; } +.muted { color: var(--muted); } +.error { color: var(--danger); } +.small { font-size: 12.5px; } + +/* layout */ +.app { display: grid; grid-template-columns: 220px 1fr; min-height: 100%; } +.sidebar { + background: var(--surface); border-right: 1px solid var(--border); + display: flex; flex-direction: column; padding: 16px 12px; gap: 16px; + position: sticky; top: 0; height: 100vh; +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; padding: 0 8px; } +nav { display: flex; flex-direction: column; gap: 2px; } +nav a { + color: var(--text); text-decoration: none; padding: 8px 10px; border-radius: 8px; +} +nav a:hover { background: var(--surface-2); } +nav a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } +.daemon-pill { + margin-top: auto; display: flex; align-items: center; gap: 8px; + padding: 8px 10px; border-radius: 8px; background: var(--surface-2); font-size: 13px; +} +.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; } +.dot.running { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.dot.starting, .dot.stopping { background: var(--warn); } +.dot.crashed { background: var(--danger); } + +main { padding: 28px 32px 48px; max-width: 1100px; width: 100%; } +.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; } +.page-head p { margin: 0; } + +.grid { display: grid; gap: 16px; } +.grid.two { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } +.card { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: 18px 20px; +} +.card + .card { margin-top: 16px; } +.grid > .card + .card { margin-top: 0; } + +.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.row.spread { justify-content: space-between; } +.stack { display: flex; flex-direction: column; gap: 10px; } + +dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; } +dl.kv dt { color: var(--muted); } +dl.kv dd { margin: 0; overflow-wrap: anywhere; } + +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; } +.stat { background: var(--surface-2); border-radius: 8px; padding: 12px 14px; } +.stat .v { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; } +.stat .l { color: var(--muted); font-size: 12.5px; } +.stat.bad .v { color: var(--danger); } + +/* controls */ +.btn { + appearance: none; border: 1px solid var(--border); background: var(--surface); + color: var(--text); padding: 7px 14px; border-radius: 8px; font: inherit; cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; +} +.btn:hover:not(:disabled) { background: var(--surface-2); } +.btn:disabled { opacity: .5; cursor: default; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; } +.btn.primary:hover:not(:disabled) { filter: brightness(1.06); background: var(--accent); } +.btn.danger { color: var(--danger); } +.btn.danger:hover:not(:disabled) { background: var(--danger-soft); } +.btn.small { padding: 3px 9px; font-size: 12.5px; } +.btn.link { border: none; background: none; color: var(--accent); padding: 0; } + +input[type=text], input[type=password], input[type=number], input[type=url], select, textarea { + font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); + border-radius: 8px; padding: 7px 10px; width: 100%; +} +textarea { resize: vertical; min-height: 70px; } +input:focus, select:focus, textarea:focus, .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +label.field { display: flex; flex-direction: column; gap: 4px; } +label.field > span { font-weight: 550; font-size: 13px; } +label.field > small { color: var(--muted); } +label.check { display: flex; align-items: center; gap: 8px; } +.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; } + +/* tables */ +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; } +th { color: var(--muted); font-weight: 550; font-size: 12.5px; } +tr:last-child td { border-bottom: none; } +td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +td.actions { text-align: right; white-space: nowrap; } +tr.clickable { cursor: pointer; } +tr.clickable:hover td { background: var(--surface-2); } +.empty { text-align: center; color: var(--muted); padding: 28px 10px; } + +.badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 12px; background: var(--surface-2); color: var(--muted); } +.badge.ok { background: var(--accent-soft); color: var(--accent); } +.badge.warn { background: var(--warn-soft); color: var(--warn); } + +.banner { border-radius: var(--radius); padding: 12px 16px; margin-bottom: 16px; display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; } +.banner.warn { background: var(--warn-soft); color: var(--text); border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); } +.banner.info { background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); } + +.crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 15px; } +.crumbs button { border: none; background: none; color: var(--accent); cursor: pointer; font: inherit; padding: 2px 4px; border-radius: 4px; } +.crumbs button:hover { background: var(--surface-2); } +.crumbs .sep { color: var(--muted); } + +.dropzone { position: relative; } +.dropzone.over::after { + content: "Drop files to upload"; position: absolute; inset: 0; display: grid; place-items: center; + background: color-mix(in srgb, var(--accent-soft) 85%, transparent); border: 2px dashed var(--accent); + border-radius: var(--radius); font-weight: 600; color: var(--accent); +} + +.uploads { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } +.upload { display: grid; grid-template-columns: 1fr 160px 70px; gap: 10px; align-items: center; font-size: 13px; } +.bar { height: 6px; background: var(--surface-2); border-radius: 3px; overflow: hidden; } +.bar > i { display: block; height: 100%; background: var(--accent); width: 0; transition: width .2s; } + +.logs { + font-family: var(--mono); font-size: 12px; background: var(--surface); border: 1px solid var(--border); + border-radius: var(--radius); padding: 12px; height: calc(100vh - 170px); overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; +} +.logs .err { color: var(--danger); } +.logs .sys { color: var(--muted); font-style: italic; } + +.secret { display: flex; gap: 6px; align-items: center; } +.secret code { background: var(--surface-2); padding: 3px 8px; border-radius: 6px; overflow-wrap: anywhere; } +pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8px; overflow: auto; margin: 0; font-family: var(--mono); font-size: 12px; } + +.phrase { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; counter-reset: w; } +.phrase span { background: var(--surface-2); padding: 6px 8px; border-radius: 6px; font-family: var(--mono); counter-increment: w; } +.phrase span::before { content: counter(w) ". "; color: var(--muted); } + +.steps { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +.steps span { padding: 3px 10px; border-radius: 999px; background: var(--surface-2); color: var(--muted); font-size: 12.5px; } +.steps span.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; } + +/* modal + toasts */ +.modal-backdrop { position: fixed; inset: 0; background: rgb(0 0 0 / .45); display: grid; place-items: center; padding: 16px; z-index: 10; } +.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px; width: min(560px, 100%); max-height: 90vh; overflow: auto; } +.modal h2 { margin-bottom: 14px; } +.modal .foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; } + +.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 20; } +.toast { background: var(--text); color: var(--bg); padding: 10px 14px; border-radius: 8px; max-width: 380px; box-shadow: 0 6px 20px rgb(0 0 0 / .2); } +.toast.error { background: var(--danger); color: #fff; } + +.signin { display: grid; place-items: center; min-height: 100%; padding: 16px; } +.signin-card { width: min(340px, 100%); display: flex; flex-direction: column; gap: 12px; align-items: stretch; text-align: center; } +.signin-card img { align-self: center; } + +.spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .8s linear infinite; display: inline-block; } +@keyframes spin { to { transform: rotate(360deg); } } + +@media (max-width: 760px) { + .app { grid-template-columns: 1fr; } + .sidebar { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; padding: 10px 16px; } + nav { flex-direction: row; flex-wrap: wrap; } + .daemon-pill { margin-top: 0; margin-left: auto; } + main { padding: 20px 16px 40px; } + .upload { grid-template-columns: 1fr 80px 50px; } + .hide-sm { display: none; } +} diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js new file mode 100644 index 00000000..afe925d7 --- /dev/null +++ b/bundled-addons/pithos/ui/app.js @@ -0,0 +1,916 @@ +// Pithos UI. Plain DOM, no build step: every host serves these files as-is. +// All user content (object keys, user names, log lines) goes in via +// textContent through h(); nothing here assigns innerHTML. + +// ---------------------------------------------------------------- helpers + +function h(tag, attrs = {}, ...children) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v == null || v === false) continue; + if (k === 'class') el.className = v; + else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + else if (k === 'dataset') Object.assign(el.dataset, v); + else if (v === true) el.setAttribute(k, ''); + else el.setAttribute(k, v); + } + for (const c of children.flat(Infinity)) { + if (c == null || c === false) continue; + el.append(c instanceof Node ? c : String(c)); + } + return el; +} + +const $ = (sel, root = document) => root.querySelector(sel); + +// replaceChildren, but skipping the null/false that conditional children produce. +function put(el, ...children) { + el.replaceChildren(...children.flat(Infinity).filter((c) => c != null && c !== false)); +} + +async function api(method, path, body, { raw } = {}) { + const opts = { method, headers: { 'x-pithos': '1' }, credentials: 'same-origin' }; + if (body !== undefined) { + opts.headers['content-type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + const res = await fetch(path, opts); + if (res.status === 401 && path !== '/api/session') { showSignin(); throw new Error('signed out'); } + if (raw) return res; + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + +function toast(msg, kind = '') { + const el = h('div', { class: `toast ${kind}` }, msg); + $('#toasts').append(el); + setTimeout(() => el.remove(), kind === 'error' ? 7000 : 3500); +} + +const fail = (e) => { if (e.message !== 'signed out') toast(e.message, 'error'); }; + +function fmtBytes(n) { + if (n == null || isNaN(n)) return '–'; + const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; + let i = 0; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return `${i ? n.toFixed(n < 10 ? 2 : 1) : n} ${u[i]}`; +} +function fmtDate(s) { if (!s) return '–'; const d = new Date(s); return isNaN(d) ? s : d.toLocaleString(); } +function fmtDuration(ms) { + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; + if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; + return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`; +} + +async function copy(text, what = 'Copied') { + try { await navigator.clipboard.writeText(text); toast(what); } + catch { prompt('Copy:', text); } +} + +function modal(title, body, buttons = []) { + return new Promise((resolve) => { + const close = (v) => { root.remove(); document.removeEventListener('keydown', onKey); resolve(v); }; + const onKey = (e) => { if (e.key === 'Escape') close(undefined); }; + const foot = h('div', { class: 'foot' }, buttons.map((b) => h('button', { + class: `btn ${b.kind || ''}`, type: b.submit ? 'submit' : 'button', + onclick: b.submit ? null : async () => { const v = b.value ? await b.value() : b.result; if (v !== false) close(v); }, + }, b.label))); + const form = h('form', { class: 'modal', onsubmit: async (e) => { + e.preventDefault(); + const b = buttons.find((x) => x.submit); + const v = b.value ? await b.value() : b.result; + if (v !== false) close(v); + } }, h('h2', {}, title), body, foot); + const root = h('div', { class: 'modal-backdrop', onmousedown: (e) => { if (e.target === root) close(undefined); } }, form); + $('#modal-root').append(root); + document.addEventListener('keydown', onKey); + setTimeout(() => (form.querySelector('input, textarea, select') || form.querySelector('.btn.primary'))?.focus(), 0); + }); +} + +function confirmModal(title, text, label = 'Delete', kind = 'danger') { + return modal(title, h('p', {}, text), [ + { label: 'Cancel', result: false }, + { label, kind: `primary ${kind === 'danger' ? '' : ''}`, submit: true, result: true }, + ]).then((v) => v === true); +} + +function secretRow(label, value) { + return h('div', { class: 'stack' }, + h('span', { class: 'small muted' }, label), + h('div', { class: 'secret' }, h('code', {}, value), h('button', { class: 'btn small', type: 'button', onclick: () => copy(value) }, 'Copy'))); +} + +// ---------------------------------------------------------------- state + +const state = { + status: null, + login: { state: 'idle' }, + logs: [], + logSeq: 0, + selectedUser: localStorage.getItem('pithos.user') || '', +}; +const listeners = new Set(); +const onState = (fn) => { listeners.add(fn); return () => listeners.delete(fn); }; +const emit = () => listeners.forEach((fn) => fn()); + +async function refreshStatus() { + try { + state.status = await api('GET', '/api/status'); + state.login = state.status.login; + renderPill(); + emit(); + } catch (e) { fail(e); } +} + +function connectEvents() { + const es = new EventSource('/api/events'); + es.addEventListener('status', (e) => { + if (!state.status) return; + state.status.daemon = JSON.parse(e.data); + renderPill(); + emit(); + }); + es.addEventListener('log', (e) => { + const l = JSON.parse(e.data); + if (l.seq <= state.logSeq) return; + state.logSeq = l.seq; + state.logs.push(l); + if (state.logs.length > 2000) state.logs.shift(); + logSink?.(l); + }); + es.addEventListener('login', (e) => { state.login = JSON.parse(e.data); emit(); }); + es.addEventListener('install', (e) => { const p = JSON.parse(e.data); if (p.phase === 'extract') toast('Verified checksum, installing…'); }); + es.onerror = () => { /* EventSource reconnects on its own */ }; +} + +function daemonState() { + const s = state.status; + if (!s) return 'stopped'; + if (s.daemon.state === 'stopped' && s.external) return 'external'; + if (s.daemon.state === 'crashed' && s.daemon.needsLogin) return 'not connected'; + return s.daemon.state; +} + +function renderPill() { + const st = daemonState(); + const label = { running: 's3d running', starting: 'Starting…', stopping: 'Stopping…', crashed: 's3d stopped (error)', 'not connected': 'Not connected to Sia', stopped: 's3d stopped', external: 's3d running (external)' }[st]; + const pill = $('#daemon-pill'); + pill.querySelector('.dot').className = `dot ${st === 'external' ? 'running' : st === 'not connected' ? 'starting' : st}`; + pill.querySelector('.label').textContent = label; +} + +// ---------------------------------------------------------------- router + +let cleanup = []; +let logSink = null; +const views = { overview, buckets, users, setup, settings, logs }; + +function route() { + cleanup.forEach((fn) => fn()); + cleanup = []; + logSink = null; + const [, name = 'overview', ...rest] = location.hash.replace(/^#/, '').split('/'); + const view = views[name] ? name : 'overview'; + for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === view); + const main = $('#main'); + put(main); + views[view](main, rest.map(decodeURIComponent)); +} + +// ---------------------------------------------------------------- overview + +function overview(main) { + const daemonCard = h('div', { class: 'card' }); + const statsCard = h('div', { class: 'card' }); + const banners = h('div'); + main.append( + h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Overview'), h('p', { class: 'muted' }, 'Your s3d gateway and its upload pipeline to Sia.'))), + banners, + h('div', { class: 'grid two' }, daemonCard, statsCard), + ); + + function draw() { + const s = state.status; + if (!s) return; + put(banners, ...bannersFor(s)); + const st = daemonState(); + const busy = st === 'starting' || st === 'stopping'; + const running = st === 'running' || st === 'starting'; + put(daemonCard, + h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)), + h('dl', { class: 'kv' }, + h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)), + s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))], + h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')), + h('dt', {}, 'Uptime'), h('dd', {}, st === 'running' && s.daemon.startedAt ? fmtDuration(Date.now() - s.daemon.startedAt) : '–'), + h('dt', {}, 'Config'), h('dd', {}, h('code', {}, s.config.file)), + h('dt', {}, 'Data'), h('dd', {}, h('code', {}, s.config.directory)), + ), + h('div', { class: 'row', style: 'margin-top:16px' }, + st === 'external' + ? h('span', { class: 'muted small' }, 'Another s3d process is serving this config. Stop it to manage it from here.') + : [ + h('button', { class: 'btn primary', disabled: busy || running || !s.daemon.binary, onclick: () => daemonAction('start') }, 'Start'), + h('button', { class: 'btn', disabled: busy || !running, onclick: () => daemonAction('restart') }, 'Restart'), + h('button', { class: 'btn danger', disabled: busy || !running, onclick: () => daemonAction('stop') }, 'Stop'), + ], + ), + ); + } + + async function drawStats() { + const st = daemonState(); + if (st !== 'running' && st !== 'external') { + put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'muted' }, 'Start s3d to see live upload stats.')); + return; + } + try { + const x = await api('GET', '/api/stats'); + const stat = (v, l, bad) => h('div', { class: `stat ${bad ? 'bad' : ''}` }, h('div', { class: 'v' }, v), h('div', { class: 'l' }, l)); + put(statsCard, + h('div', { class: 'row spread' }, h('h2', {}, 'Upload pipeline'), + h('button', { class: 'btn small', disabled: !x.pendingObjects, title: 'Upload everything waiting in the local buffer now, even if slabs are not full', onclick: flushNow }, 'Flush now')), + h('div', { class: 'stats' }, + stat(x.pendingObjects, `pending · ${fmtBytes(x.pendingSize)}`), + stat(x.uploadedObjects, `on Sia · ${fmtBytes(x.uploadedSize)}`), + stat(x.unpinnedObjects, 'awaiting pin'), + stat(x.multipartUploads, 'open multipart'), + stat(x.failedUploads, 'failed uploads', x.failedUploads > 0), + stat(x.orphanedObjects, 'orphaned', x.orphanedObjects > 0), + ), + h('p', { class: 'small muted', style: 'margin:12px 0 0' }, 'New objects wait in a local buffer and are packed into full slabs before upload, which keeps wasted space down.'), + ); + } catch (e) { + put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message)); + } + } + + async function flushNow(ev) { + const btn = ev.currentTarget; + btn.disabled = true; + put(btn, h('span', { class: 'spinner' }), ' Flushing…'); + try { await api('POST', '/api/flush'); toast('All pending objects are on Sia'); } + catch (e) { fail(e); } + drawStats(); + } + + draw(); + drawStats(); + cleanup.push(onState(draw)); + let lastState = daemonState(); + cleanup.push(onState(() => { if (daemonState() !== lastState) { lastState = daemonState(); drawStats(); } })); + const t = setInterval(() => { draw(); drawStats(); }, 4000); + cleanup.push(() => clearInterval(t)); +} + +function bannersFor(s) { + const out = []; + if (!s.daemon.binary) { + out.push(h('div', { class: 'banner warn' }, + h('span', {}, `s3d is not installed. Pithos can download the official v${s.s3d.pinned} release and check its SHA-256.`), + s.s3d.installable && h('button', { class: 'btn primary', onclick: installBinary }, `Install s3d v${s.s3d.pinned}`))); + } else if (s.s3d?.outdated) { + out.push(h('div', { class: 'banner warn' }, + h('span', {}, `s3d v${s.s3d.version} is older than v${s.s3d.pinned}. User and key listing needs v0.2.0 or newer.`), + s.s3d.installable && h('button', { class: 'btn', onclick: installBinary }, `Install v${s.s3d.pinned}`))); + } + if (s.registration?.registered === false || s.daemon.needsLogin) { + out.push(h('div', { class: 'banner info' }, + h('span', {}, 'This s3d is not connected to a Sia indexer yet.'), + h('a', { class: 'btn primary', href: '#/setup' }, 'Connect to Sia'))); + } + return out; +} + +async function installBinary(ev) { + const btn = ev.currentTarget; + btn.disabled = true; + put(btn, h('span', { class: 'spinner' }), ' Downloading…'); + try { + const r = await api('POST', '/api/binary/install'); + toast(`Installed s3d v${r.version?.version || ''}`); + } catch (e) { fail(e); } + refreshStatus(); +} + +async function daemonAction(action) { + try { + await api('POST', `/api/daemon/${action}`); + await refreshStatus(); + } catch (e) { fail(e); } +} + +// ---------------------------------------------------------------- setup (indexer login) + +function setup(main) { + const body = h('div', { class: 'card' }); + main.append( + h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Sia connection'), + h('p', { class: 'muted' }, 'Register this s3d with a Sia indexer. You do this once; the indexer then handles hosts and contracts for it.'))), + body, + ); + + let draft = { indexerUrl: 'https://sia.storage', phrase: '', mode: 'existing' }; + + function draw() { + let l = state.login; + // Registered already (e.g. on an earlier run): show that instead of the form. + if (l.state === 'idle' && state.status?.registration?.registered) l = { state: 'already', indexerUrl: state.status.registration.indexerUrl }; + const step = ['idle', 'failed'].includes(l.state) ? 0 : ['done', 'already'].includes(l.state) ? 2 : 1; + const steps = h('div', { class: 'steps' }, ['Recovery phrase', 'Approve on the indexer', 'Connected'].map((t, i) => h('span', { class: i === step ? 'on' : '' }, `${i + 1}. ${t}`))); + + if (l.state === 'done' || l.state === 'already') { + put(body, steps, + h('h2', {}, l.state === 'done' ? 'Connected' : 'Already connected'), + h('p', {}, l.indexerUrl ? ['This s3d is registered with ', h('code', {}, l.indexerUrl), '.'] : 'This s3d is registered with an indexer.'), + h('div', { class: 'row' }, + daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); location.hash = '#/overview'; } }, 'Start s3d'), + h('a', { class: 'btn', href: '#/users' }, 'Create a user'), + h('button', { class: 'btn', onclick: () => api('POST', '/api/login/cancel').catch(fail) }, 'Done'))); + return; + } + + if (l.state === 'confirm') { + const words = (l.generatedPhrase || '').split(/\s+/); + const ack = h('input', { type: 'checkbox', id: 'ack' }); + const go = h('button', { class: 'btn primary', disabled: true, onclick: () => api('POST', '/api/login/confirm').catch(fail) }, 'Continue'); + ack.addEventListener('change', () => { go.disabled = !ack.checked; }); + put(body, steps, + h('h2', {}, 'Write down your new recovery phrase'), + h('p', {}, 'This phrase is the only way to register this storage again on another machine or after a reinstall. Pithos does not keep it. Store it offline.'), + h('div', { class: 'phrase' }, words.map((w) => h('span', {}, w))), + h('div', { class: 'row', style: 'margin-top:14px' }, h('button', { class: 'btn small', onclick: () => copy(l.generatedPhrase, 'Phrase copied - clear your clipboard after storing it') }, 'Copy')), + h('label', { class: 'check', style: 'margin:16px 0' }, ack, 'I have stored the phrase somewhere safe'), + h('div', { class: 'row' }, go, h('button', { class: 'btn', onclick: cancel }, 'Cancel'))); + return; + } + + if (l.state === 'terminal') { + put(body, steps, + h('h2', {}, 'Finish in the console window'), + h('p', {}, 'A console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the approval link it prints. This page updates when the window closes.'), + h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Waiting for the login window…'), + h('div', { class: 'row' }, h('button', { class: 'btn danger', onclick: cancel }, 'Cancel'))); + return; + } + + if (step === 1) { + const waiting = l.state === 'approve' ? null : h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Talking to the indexer…'); + put(body, steps, + h('h2', {}, 'Approve the connection'), + waiting, + l.approvalUrl && [ + h('p', {}, 'Open this link, sign in to your indexer account and approve the "S3d" app. This page continues by itself once you approve.'), + h('div', { class: 'secret' }, h('code', {}, l.approvalUrl)), + h('div', { class: 'row', style: 'margin-top:12px' }, + h('button', { class: 'btn primary', onclick: () => openLink(l.approvalUrl) }, 'Open approval page'), + h('button', { class: 'btn', onclick: () => copy(l.approvalUrl) }, 'Copy link')), + h('p', { class: 'muted row', style: 'margin-top:16px' }, h('span', { class: 'spinner' }), ' Waiting for approval…'), + ], + h('div', { class: 'row', style: 'margin-top:12px' }, h('button', { class: 'btn danger', onclick: cancel }, 'Cancel'))); + return; + } + + // step 0: form + const url = h('input', { type: 'url', value: draft.indexerUrl, required: true, oninput: (e) => { draft.indexerUrl = e.target.value; } }); + const phrase = h('textarea', { rows: 3, autocomplete: 'off', spellcheck: 'false', placeholder: 'twelve words separated by spaces', oninput: (e) => { draft.phrase = e.target.value; } }); + phrase.value = draft.phrase; + const radios = h('div', { class: 'stack' }, + h('label', { class: 'check' }, h('input', { type: 'radio', name: 'mode', checked: draft.mode === 'existing', onchange: () => { draft.mode = 'existing'; draw(); } }), 'I have a recovery phrase'), + h('label', { class: 'check' }, h('input', { type: 'radio', name: 'mode', checked: draft.mode === 'new', onchange: () => { draft.mode = 'new'; draw(); } }), 'Generate a new one')); + put(body, steps, + l.state === 'failed' && h('div', { class: 'banner warn' }, h('span', {}, l.error || 'Login failed')), + h('form', { class: 'stack', onsubmit: start }, + h('label', { class: 'field' }, h('span', {}, 'Indexer'), url, h('small', {}, 'https://sia.storage is the hosted indexer run by the Sia Foundation. Use your own indexd URL if you run one.')), + radios, + draft.mode === 'existing' && h('label', { class: 'field' }, h('span', {}, 'Recovery phrase'), phrase, + h('small', {}, 'Sent only to the local s3d process. It derives this app\'s key and is not stored by Pithos.')), + h('div', { class: 'row' }, h('button', { class: 'btn primary', type: 'submit' }, 'Connect')))); + } + + async function start(e) { + e.preventDefault(); + const phrase = draft.mode === 'existing' ? draft.phrase.trim() : ''; + if (draft.mode === 'existing' && phrase.split(/\s+/).length !== 12) return toast('A recovery phrase is 12 words', 'error'); + try { + await api('POST', '/api/login', { indexerUrl: draft.indexerUrl, phrase }); + draft.phrase = ''; + } catch (err) { fail(err); } + } + + async function cancel() { try { await api('POST', '/api/login/cancel'); } catch (e) { fail(e); } } + + draw(); + cleanup.push(onState(draw)); +} + +async function openLink(url) { + const host = state.status?.host; + if (host === 'desktop' || host === 'theseus') { + try { await api('POST', '/api/open-external', { url }); return; } catch { /* fall through */ } + } + window.open(url, '_blank', 'noopener'); +} + +// ---------------------------------------------------------------- users & keys + +function users(main) { + const list = h('div', { class: 'card' }); + main.append( + h('div', { class: 'page-head' }, + h('div', {}, h('h1', {}, 'Users & keys'), h('p', { class: 'muted' }, 'Each S3 user owns its own buckets. Access keys let S3 clients (aws, rclone, apps) act as that user.')), + h('button', { class: 'btn primary', onclick: createUser }, 'New user')), + list, + ); + + let expanded = new Set(); + + async function load() { + let rows; + try { rows = await api('GET', '/api/users'); } + catch (e) { put(list, h('p', { class: 'error' }, e.message)); return; } + if (!rows.length) { + put(list, h('div', { class: 'empty' }, h('p', {}, 'No users yet.'), h('button', { class: 'btn primary', onclick: createUser }, 'Create the first user'))); + return; + } + const keys = await api('GET', '/api/keys').catch(() => []); + put(list, h('table', {}, + h('thead', {}, h('tr', {}, h('th', {}, 'User'), h('th', {}, 'Access keys'), h('th', {}))), + h('tbody', {}, rows.flatMap((u) => { + const open = expanded.has(u.name); + const userKeys = keys.filter((k) => k.user === u.name); + const main = h('tr', { class: 'clickable', onclick: () => { open ? expanded.delete(u.name) : expanded.add(u.name); load(); } }, + h('td', {}, h('strong', {}, u.name)), + h('td', {}, `${u.keys.length}`), + h('td', { class: 'actions' }, + h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); location.hash = '#/buckets'; } }, 'Buckets'), + ' ', + h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'), + ' ', + h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteUser(u.name); } }, 'Delete'))); + if (!open) return [main]; + const detail = h('tr', {}, h('td', { colspan: 3, style: 'background:var(--surface-2)' }, + userKeys.length ? h('table', {}, h('tbody', {}, userKeys.map((k) => h('tr', {}, + h('td', {}, h('code', {}, k.accessKeyId)), + h('td', { class: 'actions' }, + h('button', { class: 'btn small', onclick: () => showKey(k) }, 'Show secret & config'), + ' ', + h('button', { class: 'btn small danger', onclick: () => deleteKey(k.accessKeyId) }, 'Revoke')))))) + : h('p', { class: 'muted', style: 'margin:0' }, 'No keys. Create one to use S3 clients or browse buckets.'))); + return [main, detail]; + })))); + } + + async function createUser() { + const name = h('input', { type: 'text', required: true, pattern: '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}', placeholder: 'e.g. backups' }); + const withKey = h('input', { type: 'checkbox', checked: true }); + const v = await modal('New user', h('div', { class: 'stack' }, + h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'Letters, digits, . _ @ -')), + h('label', { class: 'check' }, withKey, 'Create an access key now')), + [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => ({ name: name.value.trim(), withKey: withKey.checked }) }]); + if (!v) return; + try { + const r = await api('POST', '/api/users', v); + toast(`Created ${r.name}`); + expanded.add(r.name); + if (!state.selectedUser) { state.selectedUser = r.name; localStorage.setItem('pithos.user', r.name); } + await load(); + if (r.key) showKey(r.key, true); + } catch (e) { fail(e); } + } + + async function newKey(user) { + try { + const k = await api('POST', '/api/keys', { user }); + expanded.add(user); + await load(); + showKey(k, true); + } catch (e) { fail(e); } + } + + async function deleteUser(name) { + if (!(await confirmModal(`Delete ${name}?`, 'Its access keys stop working immediately. s3d refuses if the user still owns buckets.'))) return; + try { await api('DELETE', `/api/users/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); } + } + + async function deleteKey(id) { + if (!(await confirmModal('Revoke this key?', `Clients using ${id} will be refused from now on.`, 'Revoke'))) return; + try { await api('DELETE', `/api/keys/${encodeURIComponent(id)}`); toast('Key revoked'); load(); } catch (e) { fail(e); } + } + + load(); +} + +function showKey(k, fresh) { + const s = state.status; + const endpoint = `http://${s?.config.apiAddress || '127.0.0.1:8000'}`; + const aws = `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user} +aws configure set aws_secret_access_key ${k.secretKey} --profile ${k.user} +aws configure set region us-east-1 --profile ${k.user} +aws --profile ${k.user} --endpoint-url ${endpoint} s3 ls`; + const rclone = `[sia-${k.user}] +type = s3 +provider = Other +access_key_id = ${k.accessKeyId} +secret_access_key = ${k.secretKey} +endpoint = ${endpoint} +region = us-east-1 +force_path_style = true`; + const env = `AWS_ACCESS_KEY_ID=${k.accessKeyId} +AWS_SECRET_ACCESS_KEY=${k.secretKey} +AWS_REGION=us-east-1 +AWS_ENDPOINT_URL=${endpoint}`; + const tabs = { 'aws cli': aws, rclone, env }; + const pre = h('pre', { class: 'snippet' }, aws); + const tabBtns = h('div', { class: 'row' }, Object.keys(tabs).map((t, i) => h('button', { + type: 'button', class: `btn small ${i === 0 ? 'primary' : ''}`, + onclick: (e) => { for (const b of tabBtns.children) b.classList.remove('primary'); e.currentTarget.classList.add('primary'); pre.textContent = tabs[t]; }, + }, t))); + modal(fresh ? `Access key for ${k.user}` : `Key ${k.accessKeyId}`, h('div', { class: 'stack' }, + fresh && h('p', { class: 'small muted', style: 'margin:0' }, 'Treat the secret like a password. You can view it again here later.'), + secretRow('Access key ID', k.accessKeyId), + secretRow('Secret key', k.secretKey), + h('h3', { style: 'margin-top:8px' }, 'Client config'), + tabBtns, pre, + h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => copy(pre.textContent) }, 'Copy snippet'))), + [{ label: 'Done', kind: 'primary', submit: true, result: true }]); +} + +// ---------------------------------------------------------------- buckets & objects + +function buckets(main, [bucket, ...prefixParts]) { + const head = h('div', { class: 'page-head' }); + const body = h('div'); + main.append(head, body); + + let users = []; + const userSel = h('select', { onchange: (e) => { state.selectedUser = e.target.value; localStorage.setItem('pithos.user', e.target.value); location.hash = '#/buckets'; route(); } }); + + (async () => { + try { users = await api('GET', '/api/users'); } + catch (e) { put(body, h('div', { class: 'card' }, h('p', { class: 'error' }, e.message))); return; } + const usable = users.filter((u) => u.keys.length); + if (!usable.length) { + put(head, h('div', {}, h('h1', {}, 'Buckets'))); + put(body, h('div', { class: 'card empty' }, h('p', {}, users.length ? 'None of your users has an access key yet.' : 'Create a user first; buckets belong to users.'), h('a', { class: 'btn primary', href: '#/users' }, 'Users & keys'))); + return; + } + if (!usable.some((u) => u.name === state.selectedUser)) state.selectedUser = usable[0].name; + put(userSel, ...usable.map((u) => h('option', { value: u.name, selected: u.name === state.selectedUser }, u.name))); + const st = daemonState(); + if (st !== 'running' && st !== 'external') { + put(head, h('div', {}, h('h1', {}, 'Buckets'))); + put(body, h('div', { class: 'card empty' }, h('p', {}, 's3d is not running.'), h('button', { class: 'btn primary', onclick: async () => { await daemonAction('start'); setTimeout(route, 1500); } }, 'Start s3d'))); + return; + } + if (bucket) objectBrowser(head, body, state.selectedUser, bucket, prefixParts.join('/'), userSel); + else bucketList(head, body, state.selectedUser, userSel); + })(); +} + +function userPicker(userSel) { + return h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Acting as'), h('span', { style: 'width:180px' }, userSel)); +} + +async function bucketList(head, body, user, userSel) { + put(head, + h('div', {}, h('h1', {}, 'Buckets'), h('p', { class: 'muted' }, 'Buckets owned by the selected user.')), + h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New bucket'))); + const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); + put(body, card); + const base = `/api/s3/${encodeURIComponent(user)}/buckets`; + + async function load() { + try { + const list = await api('GET', base); + if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No buckets yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a bucket'))); return; } + put(card, h('table', {}, + h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'hide-sm' }, 'Created'), h('th', {}, 'Access'), h('th', {}))), + h('tbody', {}, list.map((b) => { + const access = h('td', {}, h('span', { class: 'badge' }, '…')); + api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => { + put(access, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: 'private', read: 'public read', 'read-list': 'public read + list', custom: 'custom policy' }[p.public])); + }).catch(() => put(access, h('span', { class: 'badge' }, '?'))); + return h('tr', { class: 'clickable', onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } }, + h('td', {}, h('strong', {}, b.name)), + h('td', { class: 'hide-sm muted' }, fmtDate(b.created)), + access, + h('td', { class: 'actions' }, + h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); accessDialog(user, b.name).then(load); } }, 'Access'), + ' ', + h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); remove(b.name); } }, 'Delete'))); + })))); + } catch (e) { put(card, h('p', { class: 'error' }, e.message)); } + } + + async function create() { + const name = h('input', { type: 'text', required: true, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', placeholder: 'my-bucket' }); + const v = await modal('New bucket', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, '3-63 characters: lowercase letters, digits, dots and hyphens. Names are unique across this s3d.')), + [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]); + if (!v) return; + try { await api('POST', base, { name: v }); toast(`Created ${v}`); load(); } catch (e) { fail(e); } + } + + async function remove(name) { + if (!(await confirmModal(`Delete bucket ${name}?`, 'The bucket must be empty. This cannot be undone.'))) return; + try { await api('DELETE', `${base}/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); } + } + + load(); +} + +async function accessDialog(user, bucket) { + const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}/policy`; + let current = 'private'; + try { current = (await api('GET', base)).public; } catch (e) { return fail(e); } + const opts = [ + ['private', 'Private', 'Only this user\'s access keys can read.'], + ['read', 'Public read', 'Anyone who knows an object\'s URL can download it.'], + ['read-list', 'Public read + list', 'Anyone can also list the bucket\'s contents.'], + ]; + let choice = current === 'custom' ? 'private' : current; + const endpoint = `http://${state.status?.config.apiAddress}`; + const v = await modal(`Access for ${bucket}`, h('div', { class: 'stack' }, + current === 'custom' && h('div', { class: 'banner warn' }, 'This bucket has a custom policy. Saving replaces it.'), + opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' }, + h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }), + h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))), + h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/`), '. Writes and deletes always need a key.')), + [{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]); + if (!v || v === current) return; + try { await api('PUT', base, { mode: v }); toast('Access updated'); } catch (e) { fail(e); } +} + +function objectBrowser(head, body, user, bucket, prefix, userSel) { + if (prefix && !prefix.endsWith('/')) prefix += '/'; + const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}`; + const go = (p) => { location.hash = `#/buckets/${encodeURIComponent(bucket)}${p ? '/' + p.split('/').filter(Boolean).map(encodeURIComponent).join('/') : ''}`; }; + + const crumbs = h('div', { class: 'crumbs' }, + h('button', { onclick: () => { location.hash = '#/buckets'; } }, 'Buckets'), h('span', { class: 'sep' }, '/'), + h('button', { onclick: () => go('') }, bucket), + prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)])); + + const fileInput = h('input', { type: 'file', multiple: true, hidden: true, onchange: (e) => { uploadFiles([...e.target.files]); e.target.value = ''; } }); + put(head, + h('div', { class: 'stack' }, crumbs), + h('div', { class: 'row' }, userPicker(userSel), + h('button', { class: 'btn', onclick: newFolder }, 'New folder'), + h('button', { class: 'btn', onclick: () => accessDialog(user, bucket) }, 'Access'), + h('button', { class: 'btn primary', onclick: () => fileInput.click() }, 'Upload'), fileInput)); + + const uploads = h('div', { class: 'uploads' }); + const card = h('div', { class: 'card dropzone' }, h('p', { class: 'muted' }, 'Loading…')); + put(body, uploads, card); + + let depth = 0; + card.addEventListener('dragenter', (e) => { e.preventDefault(); depth++; card.classList.add('over'); }); + card.addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; card.classList.remove('over'); } }); + card.addEventListener('dragover', (e) => e.preventDefault()); + card.addEventListener('drop', (e) => { + e.preventDefault(); depth = 0; card.classList.remove('over'); + uploadFiles([...e.dataTransfer.files]); + }); + + let pages = []; + async function load(token) { + try { + const q = new URLSearchParams({ prefix }); + if (token) q.set('token', token); + const page = await api('GET', `${base}/objects?${q}`); + pages = token ? [...pages, page] : [page]; + draw(); + } catch (e) { put(card, h('p', { class: 'error' }, e.message)); } + } + + function draw() { + const folders = pages.flatMap((p) => p.folders); + const objects = pages.flatMap((p) => p.objects).filter((o) => o.key !== prefix); + const last = pages[pages.length - 1]; + if (!folders.length && !objects.length) { + put(card, h('div', { class: 'empty' }, h('p', {}, prefix ? 'This folder is empty.' : 'This bucket is empty.'), h('p', { class: 'small' }, 'Drag files here or use Upload.'))); + return; + } + put(card, h('table', {}, + h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), + h('tbody', {}, + folders.map((f) => h('tr', { class: 'clickable', onclick: () => go(f) }, + h('td', {}, '📁 ', f.slice(prefix.length)), + h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }), + h('td', { class: 'actions' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))), + objects.map((o) => h('tr', {}, + h('td', {}, h('a', { href: `${base}/object?key=${encodeURIComponent(o.key)}`, download: '' }, o.key.slice(prefix.length))), + h('td', { class: 'num' }, fmtBytes(o.size)), + h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), + h('td', { class: 'actions' }, + h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), + ' ', + h('button', { class: 'btn small danger', onclick: () => deleteObject(o.key) }, 'Delete')))))), + last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'))); + } + + function uploadFiles(files) { + for (const file of files) { + if (file.size > 5 * 1024 ** 3) { toast(`${file.name}: single uploads are limited to 5 GiB`, 'error'); continue; } + const key = prefix + file.name; + const bar = h('i'); + const pct = h('span', { class: 'muted' }, '0%'); + const row = h('div', { class: 'upload' }, h('span', {}, key), h('div', { class: 'bar' }, bar), pct); + uploads.append(row); + const xhr = new XMLHttpRequest(); + xhr.open('PUT', `${base}/object?key=${encodeURIComponent(key)}`); + xhr.setRequestHeader('x-pithos', '1'); + if (file.type) xhr.setRequestHeader('content-type', file.type); + xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const p = Math.round((e.loaded / e.total) * 100); bar.style.width = p + '%'; pct.textContent = p + '%'; } }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { pct.textContent = 'done'; setTimeout(() => row.remove(), 1500); load(); } + else { pct.textContent = 'failed'; pct.className = 'error'; let m = xhr.statusText; try { m = JSON.parse(xhr.responseText).error; } catch {} toast(`${file.name}: ${m}`, 'error'); } + }; + xhr.onerror = () => { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: network error`, 'error'); }; + xhr.send(file); + } + } + + async function newFolder() { + const name = h('input', { type: 'text', required: true, pattern: '[^/]+', placeholder: 'photos' }); + const v = await modal('New folder', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'S3 has no real folders. This opens an empty prefix; it appears once you upload into it.')), + [{ label: 'Cancel', result: null }, { label: 'Open', kind: 'primary', submit: true, value: () => name.value.trim() }]); + if (v) go(prefix + v); + } + + async function deleteObject(key) { + if (!(await confirmModal('Delete object?', key))) return; + try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); } + } + + async function deleteFolder(p) { + if (!(await confirmModal('Delete folder?', `Every object under ${p} will be deleted.`))) return; + try { const r = await api('DELETE', `${base}/prefix?prefix=${encodeURIComponent(p)}`); toast(`Deleted ${r.deleted} objects`); load(); } catch (e) { fail(e); } + } + + async function share(key) { + const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v }, l))); + const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || ''; + const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: `http://${state.status?.config.apiAddress}` }); + const out = h('div'); + const gen = async () => { + const q = new URLSearchParams({ key, expires: expires.value }); + if (endpoint.value.trim()) { q.set('endpoint', endpoint.value.trim()); localStorage.setItem('pithos.publicEndpoint', endpoint.value.trim()); } + try { + const { url } = await api('GET', `${base}/presign?${q}`); + put(out, secretRow('Link', url)); + } catch (e) { fail(e); } + return false; + }; + await modal('Share link', h('div', { class: 'stack' }, + h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'), + h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires), + h('label', { class: 'field' }, h('span', {}, 'Public address of s3d'), endpoint, h('small', {}, 'The address recipients will use to reach s3d (e.g. behind a reverse proxy). Leave blank for the local endpoint.')), + out), + [{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]); + } + + load(); +} + +// ---------------------------------------------------------------- settings + +function settings(main) { + const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); + main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Edits s3d.yml. s3d reads it at start, so a restart applies the changes.'))), card); + + const fields = [ + ['Network', [ + ['apiAddress', 'S3 API address', 'text', 'host:port the S3 API listens on. Use 0.0.0.0:8000 to serve other machines.'], + ['apiHTTPSAddress', 'S3 HTTPS address', 'text', 'Optional. s3d serves a self-signed certificate here.'], + ['adminAddress', 'Admin API address', 'text', 'Pithos reads stats from here. Keep it on loopback.'], + ['adminPassword', 'Admin API password', 'password', 'Generated on first start.'], + ['s3.hostBases', 'Virtual-host bases', 'list', 'Comma-separated domains for bucket.domain style addressing.'], + ]], + ['Storage', [ + ['directory', 'Data directory', 'text', 'Database and upload buffer. Changing it points s3d at a different store.'], + ['sia.diskUsageLimit', 'Buffer disk limit (bytes)', 'number', 'Upper bound for objects waiting to upload.'], + ['sia.uploadThreads', 'Upload threads', 'number', ''], + ['sia.dataShards', 'Data shards', 'number', 'Erasure coding: data shards (default 10). Applies to new uploads only.'], + ['sia.parityShards', 'Parity shards', 'number', 'Erasure coding: parity shards (default 20).'], + ]], + ['Logging', [ + ['log.stdout.level', 'Console log level', 'select:debug,info,warn,error', ''], + ['log.file.enabled', 'Write a log file', 'bool', ''], + ['log.file.level', 'File log level', 'select:debug,info,warn,error', ''], + ]], + ]; + + (async () => { + let cfg; + try { cfg = await api('GET', '/api/config'); } catch (e) { put(card, h('p', { class: 'error' }, e.message)); return; } + const inputs = {}; + const sections = fields.map(([title, list]) => h('div', { class: 'stack', style: 'margin-bottom:22px' }, h('h2', {}, title), h('div', { class: 'form-grid' }, list.map(([key, label, type, help]) => { + const v = cfg.values[key]; + let input; + if (type === 'bool') input = h('input', { type: 'checkbox', checked: !!v }); + else if (type.startsWith('select:')) input = h('select', {}, h('option', { value: '' }, '(default)'), type.slice(7).split(',').map((o) => h('option', { value: o, selected: v === o }, o))); + else input = h('input', { type: type === 'list' ? 'text' : type, value: Array.isArray(v) ? v.join(', ') : (v ?? ''), placeholder: '(default)', autocomplete: 'off' }); + inputs[key] = { input, type, orig: v }; + return type === 'bool' + ? h('label', { class: 'check' }, input, label) + : h('label', { class: 'field' }, h('span', {}, label), input, help && h('small', {}, help)); + })))); + put(card, + h('p', { class: 'small muted' }, 'File: ', h('code', {}, cfg.file)), + ...sections, + h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: save }, 'Save'))); + + async function save() { + const patch = {}; + for (const [key, { input, type, orig }] of Object.entries(inputs)) { + let v; + if (type === 'bool') v = input.checked; + else if (type === 'number') v = input.value === '' ? null : Number(input.value); + else if (type === 'list') v = input.value.split(',').map((s) => s.trim()).filter(Boolean); + else v = input.value.trim() || null; + const same = JSON.stringify(v ?? null) === JSON.stringify(orig ?? null) || (type === 'list' && !v.length && orig == null) || (type === 'bool' && !v && orig == null); + if (!same) patch[key] = v; + } + if (!Object.keys(patch).length) return toast('Nothing changed'); + try { + const r = await api('PUT', '/api/config', patch); + for (const k of Object.keys(patch)) inputs[k].orig = patch[k]; + if (r.restartNeeded) { + if (await confirmModal('Restart s3d?', 'The new settings apply after a restart.', 'Restart now', 'primary')) await daemonAction('restart'); + } else toast('Saved'); + refreshStatus(); + } catch (e) { fail(e); } + } + })(); +} + +// ---------------------------------------------------------------- logs + +function logs(main) { + const box = h('div', { class: 'logs' }); + let follow = true; + box.addEventListener('scroll', () => { follow = box.scrollTop + box.clientHeight >= box.scrollHeight - 20; }); + main.append( + h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Logs'), h('p', { class: 'muted' }, 'Output from the s3d process Pithos started.')), + h('button', { class: 'btn', onclick: () => copy(state.logs.map((l) => l.line).join('\n'), 'Logs copied') }, 'Copy all')), + box); + const line = (l) => h('div', { class: l.stream }, l.line); + box.append(...state.logs.map(line)); + if (!state.logs.length) box.append(h('div', { class: 'sys' }, 'No output yet.')); + box.scrollTop = box.scrollHeight; + logSink = (l) => { + box.append(line(l)); + while (box.childElementCount > 2000) box.firstChild.remove(); + if (follow) box.scrollTop = box.scrollHeight; + }; +} + +// ---------------------------------------------------------------- boot + +function showSignin() { + $('#app').hidden = true; + $('#signin').hidden = false; +} + +async function boot() { + const s = await api('GET', '/api/session'); + if (!s.authenticated) { + showSignin(); + if (!s.passwordLogin) { + $('#signin-hint').textContent = 'Open Pithos with the sign-in link printed when it started.'; + $('#signin-form').querySelector('input').hidden = true; + $('#signin-form').querySelector('button').hidden = true; + } + return; + } + $('#signin').hidden = true; + $('#app').hidden = false; + // Backfill logs the server already holds, then stream. + try { + state.logs = await api('GET', '/api/logs'); + state.logSeq = state.logs.at(-1)?.seq || 0; + } catch {} + await refreshStatus(); + connectEvents(); + window.addEventListener('hashchange', route); + route(); + setInterval(refreshStatus, 15000); +} + +$('#signin-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const err = $('#signin-error'); + err.hidden = true; + try { + await api('POST', '/api/session', { password: $('#signin-password').value }); + location.reload(); + } catch (ex) { err.textContent = ex.message; err.hidden = false; } +}); + +boot().catch(fail); diff --git a/bundled-addons/pithos/ui/icon.svg b/bundled-addons/pithos/ui/icon.svg new file mode 100644 index 00000000..4335a256 --- /dev/null +++ b/bundled-addons/pithos/ui/icon.svg @@ -0,0 +1 @@ + diff --git a/bundled-addons/pithos/ui/index.html b/bundled-addons/pithos/ui/index.html new file mode 100644 index 00000000..6a37b808 --- /dev/null +++ b/bundled-addons/pithos/ui/index.html @@ -0,0 +1,42 @@ + + + + + +Pithos + + + + + + + + + +
+ + + diff --git a/bundled-addons/pithos/vendor/yaml/LICENSE b/bundled-addons/pithos/vendor/yaml/LICENSE new file mode 100644 index 00000000..e060aaa1 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/LICENSE @@ -0,0 +1,13 @@ +Copyright Eemeli Aro + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. diff --git a/bundled-addons/pithos/vendor/yaml/lib/cli.mjs b/bundled-addons/pithos/vendor/yaml/lib/cli.mjs new file mode 100644 index 00000000..639cca4b --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/cli.mjs @@ -0,0 +1,201 @@ +import { resolve } from 'path'; +import { parseArgs } from 'util'; +import { prettyToken } from './parse/cst.js'; +import { Lexer } from './parse/lexer.js'; +import { Parser } from './parse/parser.js'; +import { Composer } from './compose/composer.js'; +import { LineCounter } from './parse/line-counter.js'; +import { prettifyError } from './errors.js'; +import { visit } from './visit.js'; + +const help = `\ +yaml: A command-line YAML processor and inspector + +Reads stdin and writes output to stdout and errors & warnings to stderr. + +Usage: + yaml Process a YAML stream, outputting it as YAML + yaml cst Parse the CST of a YAML stream + yaml lex Parse the lexical tokens of a YAML stream + yaml valid Validate a YAML stream, returning 0 on success + +Options: + --help, -h Show this message. + --json, -j Output JSON. + --indent 2 Output pretty-printed data, indented by the given number of spaces. + --merge, -m Enable support for "<<" merge keys. + +Additional options for bare "yaml" command: + --doc, -d Output pretty-printed JS Document objects. + --single, -1 Require the input to consist of a single YAML document. + --strict, -s Stop on errors. + --visit, -v Apply a visitor to each document (requires a path to import) + --yaml 1.1 Set the YAML version. (default: 1.2)`; +class UserError extends Error { + constructor(code, message) { + super(`Error: ${message}`); + this.code = code; + } +} +UserError.ARGS = 2; +UserError.SINGLE = 3; +async function cli(stdin, done, argv) { + let args; + try { + args = parseArgs({ + args: argv, + allowPositionals: true, + options: { + doc: { type: 'boolean', short: 'd' }, + help: { type: 'boolean', short: 'h' }, + indent: { type: 'string', short: 'i' }, + merge: { type: 'boolean', short: 'm' }, + json: { type: 'boolean', short: 'j' }, + single: { type: 'boolean', short: '1' }, + strict: { type: 'boolean', short: 's' }, + visit: { type: 'string', short: 'v' }, + yaml: { type: 'string', default: '1.2' } + } + }); + } + catch (error) { + return done(new UserError(UserError.ARGS, error.message)); + } + const { positionals: [mode], values: opt } = args; + let indent = Number(opt.indent); + stdin.setEncoding('utf-8'); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + switch (opt.help || mode) { + /* istanbul ignore next */ + case true: // --help + console.log(help); + break; + case 'lex': { + const lexer = new Lexer(); + const data = []; + const add = (tok) => { + if (opt.json) + data.push(tok); + else + console.log(prettyToken(tok)); + }; + stdin.on('data', (chunk) => { + for (const tok of lexer.lex(chunk, true)) + add(tok); + }); + stdin.on('end', () => { + for (const tok of lexer.lex('', false)) + add(tok); + if (opt.json) + console.log(JSON.stringify(data, null, indent)); + done(); + }); + break; + } + case 'cst': { + const parser = new Parser(); + const data = []; + const add = (tok) => { + if (opt.json) + data.push(tok); + else + console.dir(tok, { depth: null }); + }; + stdin.on('data', (chunk) => { + for (const tok of parser.parse(chunk, true)) + add(tok); + }); + stdin.on('end', () => { + for (const tok of parser.parse('', false)) + add(tok); + if (opt.json) + console.log(JSON.stringify(data, null, indent)); + done(); + }); + break; + } + case undefined: + case 'valid': { + const lineCounter = new LineCounter(); + const parser = new Parser(lineCounter.addNewLine); + // @ts-expect-error Version is validated at runtime + const composer = new Composer({ version: opt.yaml, merge: opt.merge }); + const visitor = opt.visit + ? (await import(resolve(opt.visit))).default + : null; + let source = ''; + let hasDoc = false; + let reqDocEnd = false; + const data = []; + const add = (doc) => { + if (hasDoc && opt.single) { + return done(new UserError(UserError.SINGLE, 'Input stream contains multiple documents')); + } + for (const error of doc.errors) { + prettifyError(source, lineCounter)(error); + if (opt.strict || mode === 'valid') + return done(error); + console.error(error); + } + for (const warning of doc.warnings) { + prettifyError(source, lineCounter)(warning); + console.error(warning); + } + if (visitor) + visit(doc, visitor); + if (mode === 'valid') + doc.toJS(); + else if (opt.json) + data.push(doc); + else if (opt.doc) { + Object.defineProperties(doc, { + options: { enumerable: false }, + schema: { enumerable: false } + }); + console.dir(doc, { depth: null }); + } + else { + if (reqDocEnd) + console.log('...'); + try { + indent || (indent = 2); + const str = doc.toString({ indent }); + console.log(str.endsWith('\n') ? str.slice(0, -1) : str); + } + catch (error) { + done(error); + } + } + hasDoc = true; + reqDocEnd = !doc.directives?.docEnd; + }; + stdin.on('data', (chunk) => { + source += chunk; + for (const tok of parser.parse(chunk, true)) { + for (const doc of composer.next(tok)) + add(doc); + } + }); + stdin.on('end', () => { + for (const tok of parser.parse('', false)) { + for (const doc of composer.next(tok)) + add(doc); + } + for (const doc of composer.end(false)) + add(doc); + if (opt.single && !hasDoc) { + return done(new UserError(UserError.SINGLE, 'Input stream contained no documents')); + } + if (mode !== 'valid' && opt.json) { + console.log(JSON.stringify(opt.single ? data[0] : data, null, indent)); + } + done(); + }); + break; + } + default: + done(new UserError(UserError.ARGS, `Unknown command: ${JSON.stringify(mode)}`)); + } +} + +export { UserError, cli, help }; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/compose-collection.js b/bundled-addons/pithos/vendor/yaml/lib/compose/compose-collection.js new file mode 100644 index 00000000..852efe2a --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/compose-collection.js @@ -0,0 +1,90 @@ +'use strict'; + +var identity = require('../nodes/identity.js'); +var Scalar = require('../nodes/Scalar.js'); +var YAMLMap = require('../nodes/YAMLMap.js'); +var YAMLSeq = require('../nodes/YAMLSeq.js'); +var resolveBlockMap = require('./resolve-block-map.js'); +var resolveBlockSeq = require('./resolve-block-seq.js'); +var resolveFlowCollection = require('./resolve-flow-collection.js'); + +function resolveCollection(CN, ctx, token, onError, tagName, tag) { + const coll = token.type === 'block-map' + ? resolveBlockMap.resolveBlockMap(CN, ctx, token, onError, tag) + : token.type === 'block-seq' + ? resolveBlockSeq.resolveBlockSeq(CN, ctx, token, onError, tag) + : resolveFlowCollection.resolveFlowCollection(CN, ctx, token, onError, tag); + const Coll = coll.constructor; + // If we got a tagName matching the class, or the tag name is '!', + // then use the tagName from the node class used to create it. + if (tagName === '!' || tagName === Coll.tagName) { + coll.tag = Coll.tagName; + return coll; + } + if (tagName) + coll.tag = tagName; + return coll; +} +function composeCollection(CN, ctx, token, props, onError) { + const tagToken = props.tag; + const tagName = !tagToken + ? null + : ctx.directives.tagName(tagToken.source, msg => onError(tagToken, 'TAG_RESOLVE_FAILED', msg)); + if (token.type === 'block-seq') { + const { anchor, newlineAfterProp: nl } = props; + const lastProp = anchor && tagToken + ? anchor.offset > tagToken.offset + ? anchor + : tagToken + : (anchor ?? tagToken); + if (lastProp && (!nl || nl.offset < lastProp.offset)) { + const message = 'Missing newline after block sequence props'; + onError(lastProp, 'MISSING_CHAR', message); + } + } + const expType = token.type === 'block-map' + ? 'map' + : token.type === 'block-seq' + ? 'seq' + : token.start.source === '{' + ? 'map' + : 'seq'; + // shortcut: check if it's a generic YAMLMap or YAMLSeq + // before jumping into the custom tag logic. + if (!tagToken || + !tagName || + tagName === '!' || + (tagName === YAMLMap.YAMLMap.tagName && expType === 'map') || + (tagName === YAMLSeq.YAMLSeq.tagName && expType === 'seq')) { + return resolveCollection(CN, ctx, token, onError, tagName); + } + let tag = ctx.schema.tags.find(t => t.tag === tagName && t.collection === expType); + if (!tag) { + const kt = ctx.schema.knownTags[tagName]; + if (kt?.collection === expType) { + ctx.schema.tags.push(Object.assign({}, kt, { default: false })); + tag = kt; + } + else { + if (kt) { + onError(tagToken, 'BAD_COLLECTION_TYPE', `${kt.tag} used for ${expType} collection, but expects ${kt.collection ?? 'scalar'}`, true); + } + else { + onError(tagToken, 'TAG_RESOLVE_FAILED', `Unresolved tag: ${tagName}`, true); + } + return resolveCollection(CN, ctx, token, onError, tagName); + } + } + const coll = resolveCollection(CN, ctx, token, onError, tagName, tag); + const res = tag.resolve?.(coll, msg => onError(tagToken, 'TAG_RESOLVE_FAILED', msg), ctx.options) ?? coll; + const node = identity.isNode(res) + ? res + : new Scalar.Scalar(res); + node.range = coll.range; + node.tag = tagName; + if (tag?.format) + node.format = tag.format; + return node; +} + +exports.composeCollection = composeCollection; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/compose-doc.js b/bundled-addons/pithos/vendor/yaml/lib/compose/compose-doc.js new file mode 100644 index 00000000..63c94951 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/compose-doc.js @@ -0,0 +1,45 @@ +'use strict'; + +var Document = require('../doc/Document.js'); +var composeNode = require('./compose-node.js'); +var resolveEnd = require('./resolve-end.js'); +var resolveProps = require('./resolve-props.js'); + +function composeDoc(options, directives, { offset, start, value, end }, onError) { + const opts = Object.assign({ _directives: directives }, options); + const doc = new Document.Document(undefined, opts); + const ctx = { + atKey: false, + atRoot: true, + directives: doc.directives, + options: doc.options, + schema: doc.schema + }; + const props = resolveProps.resolveProps(start, { + indicator: 'doc-start', + next: value ?? end?.[0], + offset, + onError, + parentIndent: 0, + startOnNewline: true + }); + if (props.found) { + doc.directives.docStart = true; + if (value && + (value.type === 'block-map' || value.type === 'block-seq') && + !props.hasNewline) + onError(props.end, 'MISSING_CHAR', 'Block collection cannot start on same line with directives-end marker'); + } + // @ts-expect-error If Contents is set, let's trust the user + doc.contents = value + ? composeNode.composeNode(ctx, value, props, onError) + : composeNode.composeEmptyNode(ctx, props.end, start, null, props, onError); + const contentEnd = doc.contents.range[2]; + const re = resolveEnd.resolveEnd(end, contentEnd, false, onError); + if (re.comment) + doc.comment = re.comment; + doc.range = [offset, contentEnd, re.offset]; + return doc; +} + +exports.composeDoc = composeDoc; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/compose-node.js b/bundled-addons/pithos/vendor/yaml/lib/compose/compose-node.js new file mode 100644 index 00000000..cf7047e9 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/compose-node.js @@ -0,0 +1,112 @@ +'use strict'; + +var Alias = require('../nodes/Alias.js'); +var identity = require('../nodes/identity.js'); +var composeCollection = require('./compose-collection.js'); +var composeScalar = require('./compose-scalar.js'); +var resolveEnd = require('./resolve-end.js'); +var utilEmptyScalarPosition = require('./util-empty-scalar-position.js'); + +const CN = { composeNode, composeEmptyNode }; +function composeNode(ctx, token, props, onError) { + const atKey = ctx.atKey; + const { spaceBefore, comment, anchor, tag } = props; + let node; + let isSrcToken = true; + switch (token.type) { + case 'alias': + node = composeAlias(ctx, token, onError); + if (anchor || tag) + onError(token, 'ALIAS_PROPS', 'An alias node must not specify any properties'); + break; + case 'scalar': + case 'single-quoted-scalar': + case 'double-quoted-scalar': + case 'block-scalar': + node = composeScalar.composeScalar(ctx, token, tag, onError); + if (anchor) + node.anchor = anchor.source.substring(1); + break; + case 'block-map': + case 'block-seq': + case 'flow-collection': + try { + node = composeCollection.composeCollection(CN, ctx, token, props, onError); + if (anchor) + node.anchor = anchor.source.substring(1); + } + catch (error) { + // Almost certainly here due to a stack overflow + const message = error instanceof Error ? error.message : String(error); + onError(token, 'RESOURCE_EXHAUSTION', message); + } + break; + default: { + const message = token.type === 'error' + ? token.message + : `Unsupported token (type: ${token.type})`; + onError(token, 'UNEXPECTED_TOKEN', message); + isSrcToken = false; + } + } + node ?? (node = composeEmptyNode(ctx, token.offset, undefined, null, props, onError)); + if (anchor && node.anchor === '') + onError(anchor, 'BAD_ALIAS', 'Anchor cannot be an empty string'); + if (atKey && + ctx.options.stringKeys && + (!identity.isScalar(node) || + typeof node.value !== 'string' || + (node.tag && node.tag !== 'tag:yaml.org,2002:str'))) { + const msg = 'With stringKeys, all keys must be strings'; + onError(tag ?? token, 'NON_STRING_KEY', msg); + } + if (spaceBefore) + node.spaceBefore = true; + if (comment) { + if (token.type === 'scalar' && token.source === '') + node.comment = comment; + else + node.commentBefore = comment; + } + // @ts-expect-error Type checking misses meaning of isSrcToken + if (ctx.options.keepSourceTokens && isSrcToken) + node.srcToken = token; + return node; +} +function composeEmptyNode(ctx, offset, before, pos, { spaceBefore, comment, anchor, tag, end }, onError) { + const token = { + type: 'scalar', + offset: utilEmptyScalarPosition.emptyScalarPosition(offset, before, pos), + indent: -1, + source: '' + }; + const node = composeScalar.composeScalar(ctx, token, tag, onError); + if (anchor) { + node.anchor = anchor.source.substring(1); + if (node.anchor === '') + onError(anchor, 'BAD_ALIAS', 'Anchor cannot be an empty string'); + } + if (spaceBefore) + node.spaceBefore = true; + if (comment) { + node.comment = comment; + node.range[2] = end; + } + return node; +} +function composeAlias({ options }, { offset, source, end }, onError) { + const alias = new Alias.Alias(source.substring(1)); + if (alias.source === '') + onError(offset, 'BAD_ALIAS', 'Alias cannot be an empty string'); + if (alias.source.endsWith(':')) + onError(offset + source.length - 1, 'BAD_ALIAS', 'Alias ending in : is ambiguous', true); + const valueEnd = offset + source.length; + const re = resolveEnd.resolveEnd(end, valueEnd, options.strict, onError); + alias.range = [offset, valueEnd, re.offset]; + if (re.comment) + alias.comment = re.comment; + return alias; +} + +exports.composeEmptyNode = composeEmptyNode; +exports.composeNode = composeNode; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/compose-scalar.js b/bundled-addons/pithos/vendor/yaml/lib/compose/compose-scalar.js new file mode 100644 index 00000000..7fc7ed4d --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/compose-scalar.js @@ -0,0 +1,88 @@ +'use strict'; + +var identity = require('../nodes/identity.js'); +var Scalar = require('../nodes/Scalar.js'); +var resolveBlockScalar = require('./resolve-block-scalar.js'); +var resolveFlowScalar = require('./resolve-flow-scalar.js'); + +function composeScalar(ctx, token, tagToken, onError) { + const { value, type, comment, range } = token.type === 'block-scalar' + ? resolveBlockScalar.resolveBlockScalar(ctx, token, onError) + : resolveFlowScalar.resolveFlowScalar(token, ctx.options.strict, onError); + const tagName = tagToken + ? ctx.directives.tagName(tagToken.source, msg => onError(tagToken, 'TAG_RESOLVE_FAILED', msg)) + : null; + let tag; + if (ctx.options.stringKeys && ctx.atKey) { + tag = ctx.schema[identity.SCALAR]; + } + else if (tagName) + tag = findScalarTagByName(ctx.schema, value, tagName, tagToken, onError); + else if (token.type === 'scalar') + tag = findScalarTagByTest(ctx, value, token, onError); + else + tag = ctx.schema[identity.SCALAR]; + let scalar; + try { + const res = tag.resolve(value, msg => onError(tagToken ?? token, 'TAG_RESOLVE_FAILED', msg), ctx.options); + scalar = identity.isScalar(res) ? res : new Scalar.Scalar(res); + } + catch (error) { + const msg = error instanceof Error ? error.message : String(error); + onError(tagToken ?? token, 'TAG_RESOLVE_FAILED', msg); + scalar = new Scalar.Scalar(value); + } + scalar.range = range; + scalar.source = value; + if (type) + scalar.type = type; + if (tagName) + scalar.tag = tagName; + if (tag.format) + scalar.format = tag.format; + if (comment) + scalar.comment = comment; + return scalar; +} +function findScalarTagByName(schema, value, tagName, tagToken, onError) { + if (tagName === '!') + return schema[identity.SCALAR]; // non-specific tag + const matchWithTest = []; + for (const tag of schema.tags) { + if (!tag.collection && tag.tag === tagName) { + if (tag.default && tag.test) + matchWithTest.push(tag); + else + return tag; + } + } + for (const tag of matchWithTest) + if (tag.test?.test(value)) + return tag; + const kt = schema.knownTags[tagName]; + if (kt && !kt.collection) { + // Ensure that the known tag is available for stringifying, + // but does not get used by default. + schema.tags.push(Object.assign({}, kt, { default: false, test: undefined })); + return kt; + } + onError(tagToken, 'TAG_RESOLVE_FAILED', `Unresolved tag: ${tagName}`, tagName !== 'tag:yaml.org,2002:str'); + return schema[identity.SCALAR]; +} +function findScalarTagByTest({ atKey, directives, schema }, value, token, onError) { + const tag = schema.tags.find(tag => (tag.default === true || (atKey && tag.default === 'key')) && + tag.test?.test(value)) || schema[identity.SCALAR]; + if (schema.compat) { + const compat = schema.compat.find(tag => tag.default && tag.test?.test(value)) ?? + schema[identity.SCALAR]; + if (tag.tag !== compat.tag) { + const ts = directives.tagString(tag.tag); + const cs = directives.tagString(compat.tag); + const msg = `Value may be parsed as either ${ts} or ${cs}`; + onError(token, 'TAG_RESOLVE_FAILED', msg, true); + } + } + return tag; +} + +exports.composeScalar = composeScalar; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/composer.js b/bundled-addons/pithos/vendor/yaml/lib/compose/composer.js new file mode 100644 index 00000000..f552d69a --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/composer.js @@ -0,0 +1,224 @@ +'use strict'; + +var node_process = require('process'); +var directives = require('../doc/directives.js'); +var Document = require('../doc/Document.js'); +var errors = require('../errors.js'); +var identity = require('../nodes/identity.js'); +var composeDoc = require('./compose-doc.js'); +var resolveEnd = require('./resolve-end.js'); + +function getErrorPos(src) { + if (typeof src === 'number') + return [src, src + 1]; + if (Array.isArray(src)) + return src.length === 2 ? src : [src[0], src[1]]; + const { offset, source } = src; + return [offset, offset + (typeof source === 'string' ? source.length : 1)]; +} +function parsePrelude(prelude) { + let comment = ''; + let atComment = false; + let afterEmptyLine = false; + for (let i = 0; i < prelude.length; ++i) { + const source = prelude[i]; + switch (source[0]) { + case '#': + comment += + (comment === '' ? '' : afterEmptyLine ? '\n\n' : '\n') + + (source.substring(1) || ' '); + atComment = true; + afterEmptyLine = false; + break; + case '%': + if (prelude[i + 1]?.[0] !== '#') + i += 1; + atComment = false; + break; + default: + // This may be wrong after doc-end, but in that case it doesn't matter + if (!atComment) + afterEmptyLine = true; + atComment = false; + } + } + return { comment, afterEmptyLine }; +} +/** + * Compose a stream of CST nodes into a stream of YAML Documents. + * + * ```ts + * import { Composer, Parser } from 'yaml' + * + * const src: string = ... + * const tokens = new Parser().parse(src) + * const docs = new Composer().compose(tokens) + * ``` + */ +class Composer { + constructor(options = {}) { + this.doc = null; + this.atDirectives = false; + this.prelude = []; + this.errors = []; + this.warnings = []; + this.onError = (source, code, message, warning) => { + const pos = getErrorPos(source); + if (warning) + this.warnings.push(new errors.YAMLWarning(pos, code, message)); + else + this.errors.push(new errors.YAMLParseError(pos, code, message)); + }; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + this.directives = new directives.Directives({ version: options.version || '1.2' }); + this.options = options; + } + decorate(doc, afterDoc) { + const { comment, afterEmptyLine } = parsePrelude(this.prelude); + //console.log({ dc: doc.comment, prelude, comment }) + if (comment) { + const dc = doc.contents; + if (afterDoc) { + doc.comment = doc.comment ? `${doc.comment}\n${comment}` : comment; + } + else if (afterEmptyLine || doc.directives.docStart || !dc) { + doc.commentBefore = comment; + } + else if (identity.isCollection(dc) && !dc.flow && dc.items.length > 0) { + let it = dc.items[0]; + if (identity.isPair(it)) + it = it.key; + const cb = it.commentBefore; + it.commentBefore = cb ? `${comment}\n${cb}` : comment; + } + else { + const cb = dc.commentBefore; + dc.commentBefore = cb ? `${comment}\n${cb}` : comment; + } + } + if (afterDoc) { + for (let i = 0; i < this.errors.length; ++i) + doc.errors.push(this.errors[i]); + for (let i = 0; i < this.warnings.length; ++i) + doc.warnings.push(this.warnings[i]); + } + else { + doc.errors = this.errors; + doc.warnings = this.warnings; + } + this.prelude = []; + this.errors = []; + this.warnings = []; + } + /** + * Current stream status information. + * + * Mostly useful at the end of input for an empty stream. + */ + streamInfo() { + return { + comment: parsePrelude(this.prelude).comment, + directives: this.directives, + errors: this.errors, + warnings: this.warnings + }; + } + /** + * Compose tokens into documents. + * + * @param forceDoc - If the stream contains no document, still emit a final document including any comments and directives that would be applied to a subsequent document. + * @param endOffset - Should be set if `forceDoc` is also set, to set the document range end and to indicate errors correctly. + */ + *compose(tokens, forceDoc = false, endOffset = -1) { + for (const token of tokens) + yield* this.next(token); + yield* this.end(forceDoc, endOffset); + } + /** Advance the composer by one CST token. */ + *next(token) { + if (node_process.env.LOG_STREAM) + console.dir(token, { depth: null }); + switch (token.type) { + case 'directive': + this.directives.add(token.source, (offset, message, warning) => { + const pos = getErrorPos(token); + pos[0] += offset; + this.onError(pos, 'BAD_DIRECTIVE', message, warning); + }); + this.prelude.push(token.source); + this.atDirectives = true; + break; + case 'document': { + const doc = composeDoc.composeDoc(this.options, this.directives, token, this.onError); + if (this.atDirectives && !doc.directives.docStart) + this.onError(token, 'MISSING_CHAR', 'Missing directives-end/doc-start indicator line'); + this.decorate(doc, false); + if (this.doc) + yield this.doc; + this.doc = doc; + this.atDirectives = false; + break; + } + case 'byte-order-mark': + case 'space': + break; + case 'comment': + case 'newline': + this.prelude.push(token.source); + break; + case 'error': { + const msg = token.source + ? `${token.message}: ${JSON.stringify(token.source)}` + : token.message; + const error = new errors.YAMLParseError(getErrorPos(token), 'UNEXPECTED_TOKEN', msg); + if (this.atDirectives || !this.doc) + this.errors.push(error); + else + this.doc.errors.push(error); + break; + } + case 'doc-end': { + if (!this.doc) { + const msg = 'Unexpected doc-end without preceding document'; + this.errors.push(new errors.YAMLParseError(getErrorPos(token), 'UNEXPECTED_TOKEN', msg)); + break; + } + this.doc.directives.docEnd = true; + const end = resolveEnd.resolveEnd(token.end, token.offset + token.source.length, this.doc.options.strict, this.onError); + this.decorate(this.doc, true); + if (end.comment) { + const dc = this.doc.comment; + this.doc.comment = dc ? `${dc}\n${end.comment}` : end.comment; + } + this.doc.range[2] = end.offset; + break; + } + default: + this.errors.push(new errors.YAMLParseError(getErrorPos(token), 'UNEXPECTED_TOKEN', `Unsupported token ${token.type}`)); + } + } + /** + * Call at end of input to yield any remaining document. + * + * @param forceDoc - If the stream contains no document, still emit a final document including any comments and directives that would be applied to a subsequent document. + * @param endOffset - Should be set if `forceDoc` is also set, to set the document range end and to indicate errors correctly. + */ + *end(forceDoc = false, endOffset = -1) { + if (this.doc) { + this.decorate(this.doc, true); + yield this.doc; + this.doc = null; + } + else if (forceDoc) { + const opts = Object.assign({ _directives: this.directives }, this.options); + const doc = new Document.Document(undefined, opts); + if (this.atDirectives) + this.onError(endOffset, 'MISSING_CHAR', 'Missing directives-end indicator line'); + doc.range = [0, endOffset, endOffset]; + this.decorate(doc, false); + yield doc; + } + } +} + +exports.Composer = Composer; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-block-map.js b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-block-map.js new file mode 100644 index 00000000..f0d97272 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-block-map.js @@ -0,0 +1,117 @@ +'use strict'; + +var Pair = require('../nodes/Pair.js'); +var YAMLMap = require('../nodes/YAMLMap.js'); +var resolveProps = require('./resolve-props.js'); +var utilContainsNewline = require('./util-contains-newline.js'); +var utilFlowIndentCheck = require('./util-flow-indent-check.js'); +var utilMapIncludes = require('./util-map-includes.js'); + +const startColMsg = 'All mapping items must start at the same column'; +function resolveBlockMap({ composeNode, composeEmptyNode }, ctx, bm, onError, tag) { + const NodeClass = tag?.nodeClass ?? YAMLMap.YAMLMap; + const map = new NodeClass(ctx.schema); + if (ctx.atRoot) + ctx.atRoot = false; + let offset = bm.offset; + let commentEnd = null; + for (const collItem of bm.items) { + const { start, key, sep, value } = collItem; + // key properties + const keyProps = resolveProps.resolveProps(start, { + indicator: 'explicit-key-ind', + next: key ?? sep?.[0], + offset, + onError, + parentIndent: bm.indent, + startOnNewline: true + }); + const implicitKey = !keyProps.found; + if (implicitKey) { + if (key) { + if (key.type === 'block-seq') + onError(offset, 'BLOCK_AS_IMPLICIT_KEY', 'A block sequence may not be used as an implicit map key'); + else if ('indent' in key && key.indent !== bm.indent) + onError(offset, 'BAD_INDENT', startColMsg); + } + if (!keyProps.anchor && !keyProps.tag && !sep) { + commentEnd = keyProps.end; + if (keyProps.comment) { + if (map.comment) + map.comment += '\n' + keyProps.comment; + else + map.comment = keyProps.comment; + } + continue; + } + if (keyProps.newlineAfterProp || utilContainsNewline.containsNewline(key)) { + onError(key ?? start[start.length - 1], 'MULTILINE_IMPLICIT_KEY', 'Implicit keys need to be on a single line'); + } + } + else if (keyProps.found?.indent !== bm.indent) { + onError(offset, 'BAD_INDENT', startColMsg); + } + // key value + ctx.atKey = true; + const keyStart = keyProps.end; + const keyNode = key + ? composeNode(ctx, key, keyProps, onError) + : composeEmptyNode(ctx, keyStart, start, null, keyProps, onError); + if (ctx.schema.compat) + utilFlowIndentCheck.flowIndentCheck(bm.indent, key, onError); + ctx.atKey = false; + if (utilMapIncludes.mapIncludes(ctx, map.items, keyNode)) + onError(keyStart, 'DUPLICATE_KEY', 'Map keys must be unique'); + // value properties + const valueProps = resolveProps.resolveProps(sep ?? [], { + indicator: 'map-value-ind', + next: value, + offset: keyNode.range[2], + onError, + parentIndent: bm.indent, + startOnNewline: !key || key.type === 'block-scalar' + }); + offset = valueProps.end; + if (valueProps.found) { + if (implicitKey) { + if (value?.type === 'block-map' && !valueProps.hasNewline) + onError(offset, 'BLOCK_AS_IMPLICIT_KEY', 'Nested mappings are not allowed in compact mappings'); + if (ctx.options.strict && + keyProps.start < valueProps.found.offset - 1024) + onError(keyNode.range, 'KEY_OVER_1024_CHARS', 'The : indicator must be at most 1024 chars after the start of an implicit block mapping key'); + } + // value value + const valueNode = value + ? composeNode(ctx, value, valueProps, onError) + : composeEmptyNode(ctx, offset, sep, null, valueProps, onError); + if (ctx.schema.compat) + utilFlowIndentCheck.flowIndentCheck(bm.indent, value, onError); + offset = valueNode.range[2]; + const pair = new Pair.Pair(keyNode, valueNode); + if (ctx.options.keepSourceTokens) + pair.srcToken = collItem; + map.items.push(pair); + } + else { + // key with no value + if (implicitKey) + onError(keyNode.range, 'MISSING_CHAR', 'Implicit map keys need to be followed by map values'); + if (valueProps.comment) { + if (keyNode.comment) + keyNode.comment += '\n' + valueProps.comment; + else + keyNode.comment = valueProps.comment; + } + const pair = new Pair.Pair(keyNode); + if (ctx.options.keepSourceTokens) + pair.srcToken = collItem; + map.items.push(pair); + } + } + if (commentEnd && commentEnd < offset) + onError(commentEnd, 'IMPOSSIBLE', 'Map comment with trailing content'); + map.range = [bm.offset, offset, commentEnd ?? offset]; + return map; +} + +exports.resolveBlockMap = resolveBlockMap; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-block-scalar.js b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-block-scalar.js new file mode 100644 index 00000000..97eaf2b5 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-block-scalar.js @@ -0,0 +1,200 @@ +'use strict'; + +var Scalar = require('../nodes/Scalar.js'); + +function resolveBlockScalar(ctx, scalar, onError) { + const start = scalar.offset; + const header = parseBlockScalarHeader(scalar, ctx.options.strict, onError); + if (!header) + return { value: '', type: null, comment: '', range: [start, start, start] }; + const type = header.mode === '>' ? Scalar.Scalar.BLOCK_FOLDED : Scalar.Scalar.BLOCK_LITERAL; + const lines = scalar.source ? splitLines(scalar.source) : []; + // determine the end of content & start of chomping + let chompStart = lines.length; + for (let i = lines.length - 1; i >= 0; --i) { + const content = lines[i][1]; + if (content === '' || content === '\r') + chompStart = i; + else + break; + } + // shortcut for empty contents + if (chompStart === 0) { + const value = header.chomp === '+' && lines.length > 0 + ? '\n'.repeat(Math.max(1, lines.length - 1)) + : ''; + let end = start + header.length; + if (scalar.source) + end += scalar.source.length; + return { value, type, comment: header.comment, range: [start, end, end] }; + } + // find the indentation level to trim from start + let trimIndent = scalar.indent + header.indent; + let offset = scalar.offset + header.length; + let contentStart = 0; + for (let i = 0; i < chompStart; ++i) { + const [indent, content] = lines[i]; + if (content === '' || content === '\r') { + if (header.indent === 0 && indent.length > trimIndent) + trimIndent = indent.length; + } + else { + if (indent.length < trimIndent) { + const message = 'Block scalars with more-indented leading empty lines must use an explicit indentation indicator'; + onError(offset + indent.length, 'MISSING_CHAR', message); + } + if (header.indent === 0) + trimIndent = indent.length; + contentStart = i; + if (trimIndent === 0 && !ctx.atRoot) { + const message = 'Block scalar values in collections must be indented'; + onError(offset, 'BAD_INDENT', message); + } + break; + } + offset += indent.length + content.length + 1; + } + // include trailing more-indented empty lines in content + for (let i = lines.length - 1; i >= chompStart; --i) { + if (lines[i][0].length > trimIndent) + chompStart = i + 1; + } + let value = ''; + let sep = ''; + let prevMoreIndented = false; + // leading whitespace is kept intact + for (let i = 0; i < contentStart; ++i) + value += lines[i][0].slice(trimIndent) + '\n'; + for (let i = contentStart; i < chompStart; ++i) { + let [indent, content] = lines[i]; + offset += indent.length + content.length + 1; + const crlf = content[content.length - 1] === '\r'; + if (crlf) + content = content.slice(0, -1); + /* istanbul ignore if already caught in lexer */ + if (content && indent.length < trimIndent) { + const src = header.indent + ? 'explicit indentation indicator' + : 'first line'; + const message = `Block scalar lines must not be less indented than their ${src}`; + onError(offset - content.length - (crlf ? 2 : 1), 'BAD_INDENT', message); + indent = ''; + } + if (type === Scalar.Scalar.BLOCK_LITERAL) { + value += sep + indent.slice(trimIndent) + content; + sep = '\n'; + } + else if (indent.length > trimIndent || content[0] === '\t') { + // more-indented content within a folded block + if (sep === ' ') + sep = '\n'; + else if (!prevMoreIndented && sep === '\n') + sep = '\n\n'; + value += sep + indent.slice(trimIndent) + content; + sep = '\n'; + prevMoreIndented = true; + } + else if (content === '') { + // empty line + if (sep === '\n') + value += '\n'; + else + sep = '\n'; + } + else { + value += sep + content; + sep = ' '; + prevMoreIndented = false; + } + } + switch (header.chomp) { + case '-': + break; + case '+': + for (let i = chompStart; i < lines.length; ++i) + value += '\n' + lines[i][0].slice(trimIndent); + if (value[value.length - 1] !== '\n') + value += '\n'; + break; + default: + value += '\n'; + } + const end = start + header.length + scalar.source.length; + return { value, type, comment: header.comment, range: [start, end, end] }; +} +function parseBlockScalarHeader({ offset, props }, strict, onError) { + /* istanbul ignore if should not happen */ + if (props[0].type !== 'block-scalar-header') { + onError(props[0], 'IMPOSSIBLE', 'Block scalar header not found'); + return null; + } + const { source } = props[0]; + const mode = source[0]; + let indent = 0; + let chomp = ''; + let error = -1; + for (let i = 1; i < source.length; ++i) { + const ch = source[i]; + if (!chomp && (ch === '-' || ch === '+')) + chomp = ch; + else { + const n = Number(ch); + if (!indent && n) + indent = n; + else if (error === -1) + error = offset + i; + } + } + if (error !== -1) + onError(error, 'UNEXPECTED_TOKEN', `Block scalar header includes extra characters: ${source}`); + let hasSpace = false; + let comment = ''; + let length = source.length; + for (let i = 1; i < props.length; ++i) { + const token = props[i]; + switch (token.type) { + case 'space': + hasSpace = true; + // fallthrough + case 'newline': + length += token.source.length; + break; + case 'comment': + if (strict && !hasSpace) { + const message = 'Comments must be separated from other tokens by white space characters'; + onError(token, 'MISSING_CHAR', message); + } + length += token.source.length; + comment = token.source.substring(1); + break; + case 'error': + onError(token, 'UNEXPECTED_TOKEN', token.message); + length += token.source.length; + break; + /* istanbul ignore next should not happen */ + default: { + const message = `Unexpected token in block scalar header: ${token.type}`; + onError(token, 'UNEXPECTED_TOKEN', message); + const ts = token.source; + if (ts && typeof ts === 'string') + length += ts.length; + } + } + } + return { mode, indent, chomp, comment, length }; +} +/** @returns Array of lines split up as `[indent, content]` */ +function splitLines(source) { + const split = source.split(/\n( *)/); + const first = split[0]; + const m = first.match(/^( *)/); + const line0 = m?.[1] + ? [m[1], first.slice(m[1].length)] + : ['', first]; + const lines = [line0]; + for (let i = 1; i < split.length; i += 2) + lines.push([split[i], split[i + 1]]); + return lines; +} + +exports.resolveBlockScalar = resolveBlockScalar; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-block-seq.js b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-block-seq.js new file mode 100644 index 00000000..9c834fc4 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-block-seq.js @@ -0,0 +1,51 @@ +'use strict'; + +var YAMLSeq = require('../nodes/YAMLSeq.js'); +var resolveProps = require('./resolve-props.js'); +var utilFlowIndentCheck = require('./util-flow-indent-check.js'); + +function resolveBlockSeq({ composeNode, composeEmptyNode }, ctx, bs, onError, tag) { + const NodeClass = tag?.nodeClass ?? YAMLSeq.YAMLSeq; + const seq = new NodeClass(ctx.schema); + if (ctx.atRoot) + ctx.atRoot = false; + if (ctx.atKey) + ctx.atKey = false; + let offset = bs.offset; + let commentEnd = null; + for (const { start, value } of bs.items) { + const props = resolveProps.resolveProps(start, { + indicator: 'seq-item-ind', + next: value, + offset, + onError, + parentIndent: bs.indent, + startOnNewline: true + }); + if (!props.found) { + if (props.anchor || props.tag || value) { + if (value?.type === 'block-seq') + onError(props.end, 'BAD_INDENT', 'All sequence items must start at the same column'); + else + onError(offset, 'MISSING_CHAR', 'Sequence item without - indicator'); + } + else { + commentEnd = props.end; + if (props.comment) + seq.comment = props.comment; + continue; + } + } + const node = value + ? composeNode(ctx, value, props, onError) + : composeEmptyNode(ctx, props.end, start, null, props, onError); + if (ctx.schema.compat) + utilFlowIndentCheck.flowIndentCheck(bs.indent, value, onError); + offset = node.range[2]; + seq.items.push(node); + } + seq.range = [bs.offset, offset, commentEnd ?? offset]; + return seq; +} + +exports.resolveBlockSeq = resolveBlockSeq; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-end.js b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-end.js new file mode 100644 index 00000000..3a583477 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-end.js @@ -0,0 +1,39 @@ +'use strict'; + +function resolveEnd(end, offset, reqSpace, onError) { + let comment = ''; + if (end) { + let hasSpace = false; + let sep = ''; + for (const token of end) { + const { source, type } = token; + switch (type) { + case 'space': + hasSpace = true; + break; + case 'comment': { + if (reqSpace && !hasSpace) + onError(token, 'MISSING_CHAR', 'Comments must be separated from other tokens by white space characters'); + const cb = source.substring(1) || ' '; + if (!comment) + comment = cb; + else + comment += sep + cb; + sep = ''; + break; + } + case 'newline': + if (comment) + sep += source; + hasSpace = true; + break; + default: + onError(token, 'UNEXPECTED_TOKEN', `Unexpected ${type} at node end`); + } + offset += source.length; + } + } + return { comment, offset }; +} + +exports.resolveEnd = resolveEnd; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-flow-collection.js b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-flow-collection.js new file mode 100644 index 00000000..960ad31f --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-flow-collection.js @@ -0,0 +1,209 @@ +'use strict'; + +var identity = require('../nodes/identity.js'); +var Pair = require('../nodes/Pair.js'); +var YAMLMap = require('../nodes/YAMLMap.js'); +var YAMLSeq = require('../nodes/YAMLSeq.js'); +var resolveEnd = require('./resolve-end.js'); +var resolveProps = require('./resolve-props.js'); +var utilContainsNewline = require('./util-contains-newline.js'); +var utilMapIncludes = require('./util-map-includes.js'); + +const blockMsg = 'Block collections are not allowed within flow collections'; +const isBlock = (token) => token && (token.type === 'block-map' || token.type === 'block-seq'); +function resolveFlowCollection({ composeNode, composeEmptyNode }, ctx, fc, onError, tag) { + const isMap = fc.start.source === '{'; + const fcName = isMap ? 'flow map' : 'flow sequence'; + const NodeClass = (tag?.nodeClass ?? (isMap ? YAMLMap.YAMLMap : YAMLSeq.YAMLSeq)); + const coll = new NodeClass(ctx.schema); + coll.flow = true; + const atRoot = ctx.atRoot; + if (atRoot) + ctx.atRoot = false; + if (ctx.atKey) + ctx.atKey = false; + let offset = fc.offset + fc.start.source.length; + for (let i = 0; i < fc.items.length; ++i) { + const collItem = fc.items[i]; + const { start, key, sep, value } = collItem; + const props = resolveProps.resolveProps(start, { + flow: fcName, + indicator: 'explicit-key-ind', + next: key ?? sep?.[0], + offset, + onError, + parentIndent: fc.indent, + startOnNewline: false + }); + if (!props.found) { + if (!props.anchor && !props.tag && !sep && !value) { + if (i === 0 && props.comma) + onError(props.comma, 'UNEXPECTED_TOKEN', `Unexpected , in ${fcName}`); + else if (i < fc.items.length - 1) + onError(props.start, 'UNEXPECTED_TOKEN', `Unexpected empty item in ${fcName}`); + if (props.comment) { + if (coll.comment) + coll.comment += '\n' + props.comment; + else + coll.comment = props.comment; + } + offset = props.end; + continue; + } + if (!isMap && ctx.options.strict && utilContainsNewline.containsNewline(key)) + onError(key, // checked by containsNewline() + 'MULTILINE_IMPLICIT_KEY', 'Implicit keys of flow sequence pairs need to be on a single line'); + } + if (i === 0) { + if (props.comma) + onError(props.comma, 'UNEXPECTED_TOKEN', `Unexpected , in ${fcName}`); + } + else { + if (!props.comma) + onError(props.start, 'MISSING_CHAR', `Missing , between ${fcName} items`); + if (props.comment) { + let prevItemComment = ''; + loop: for (const st of start) { + switch (st.type) { + case 'comma': + case 'space': + break; + case 'comment': + prevItemComment = st.source.substring(1); + break loop; + default: + break loop; + } + } + if (prevItemComment) { + let prev = coll.items[coll.items.length - 1]; + if (identity.isPair(prev)) + prev = prev.value ?? prev.key; + if (prev.comment) + prev.comment += '\n' + prevItemComment; + else + prev.comment = prevItemComment; + props.comment = props.comment.substring(prevItemComment.length + 1); + } + } + } + if (!isMap && !sep && !props.found) { + // item is a value in a seq + // → key & sep are empty, start does not include ? or : + const valueNode = value + ? composeNode(ctx, value, props, onError) + : composeEmptyNode(ctx, props.end, sep, null, props, onError); + coll.items.push(valueNode); + offset = valueNode.range[2]; + if (isBlock(value)) + onError(valueNode.range, 'BLOCK_IN_FLOW', blockMsg); + } + else { + // item is a key+value pair + // key value + ctx.atKey = true; + const keyStart = props.end; + const keyNode = key + ? composeNode(ctx, key, props, onError) + : composeEmptyNode(ctx, keyStart, start, null, props, onError); + if (isBlock(key)) + onError(keyNode.range, 'BLOCK_IN_FLOW', blockMsg); + ctx.atKey = false; + // value properties + const valueProps = resolveProps.resolveProps(sep ?? [], { + flow: fcName, + indicator: 'map-value-ind', + next: value, + offset: keyNode.range[2], + onError, + parentIndent: fc.indent, + startOnNewline: false + }); + if (valueProps.found) { + if (!isMap && !props.found && ctx.options.strict) { + if (sep) + for (const st of sep) { + if (st === valueProps.found) + break; + if (st.type === 'newline') { + onError(st, 'MULTILINE_IMPLICIT_KEY', 'Implicit keys of flow sequence pairs need to be on a single line'); + break; + } + } + if (props.start < valueProps.found.offset - 1024) + onError(valueProps.found, 'KEY_OVER_1024_CHARS', 'The : indicator must be at most 1024 chars after the start of an implicit flow sequence key'); + } + } + else if (value) { + if ('source' in value && value.source?.[0] === ':') + onError(value, 'MISSING_CHAR', `Missing space after : in ${fcName}`); + else + onError(valueProps.start, 'MISSING_CHAR', `Missing , or : between ${fcName} items`); + } + // value value + const valueNode = value + ? composeNode(ctx, value, valueProps, onError) + : valueProps.found + ? composeEmptyNode(ctx, valueProps.end, sep, null, valueProps, onError) + : null; + if (valueNode) { + if (isBlock(value)) + onError(valueNode.range, 'BLOCK_IN_FLOW', blockMsg); + } + else if (valueProps.comment) { + if (keyNode.comment) + keyNode.comment += '\n' + valueProps.comment; + else + keyNode.comment = valueProps.comment; + } + const pair = new Pair.Pair(keyNode, valueNode); + if (ctx.options.keepSourceTokens) + pair.srcToken = collItem; + if (isMap) { + const map = coll; + if (utilMapIncludes.mapIncludes(ctx, map.items, keyNode)) + onError(keyStart, 'DUPLICATE_KEY', 'Map keys must be unique'); + map.items.push(pair); + } + else { + const map = new YAMLMap.YAMLMap(ctx.schema); + map.flow = true; + map.items.push(pair); + const endRange = (valueNode ?? keyNode).range; + map.range = [keyNode.range[0], endRange[1], endRange[2]]; + coll.items.push(map); + } + offset = valueNode ? valueNode.range[2] : valueProps.end; + } + } + const expectedEnd = isMap ? '}' : ']'; + const [ce, ...ee] = fc.end; + let cePos = offset; + if (ce?.source === expectedEnd) + cePos = ce.offset + ce.source.length; + else { + const name = fcName[0].toUpperCase() + fcName.substring(1); + const msg = atRoot + ? `${name} must end with a ${expectedEnd}` + : `${name} in block collection must be sufficiently indented and end with a ${expectedEnd}`; + onError(offset, atRoot ? 'MISSING_CHAR' : 'BAD_INDENT', msg); + if (ce && ce.source.length !== 1) + ee.unshift(ce); + } + if (ee.length > 0) { + const end = resolveEnd.resolveEnd(ee, cePos, ctx.options.strict, onError); + if (end.comment) { + if (coll.comment) + coll.comment += '\n' + end.comment; + else + coll.comment = end.comment; + } + coll.range = [fc.offset, cePos, end.offset]; + } + else { + coll.range = [fc.offset, cePos, cePos]; + } + return coll; +} + +exports.resolveFlowCollection = resolveFlowCollection; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-flow-scalar.js b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-flow-scalar.js new file mode 100644 index 00000000..be1262a1 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-flow-scalar.js @@ -0,0 +1,228 @@ +'use strict'; + +var Scalar = require('../nodes/Scalar.js'); +var resolveEnd = require('./resolve-end.js'); + +function resolveFlowScalar(scalar, strict, onError) { + const { offset, type, source, end } = scalar; + let _type; + let value; + const _onError = (rel, code, msg) => onError(offset + rel, code, msg); + switch (type) { + case 'scalar': + _type = Scalar.Scalar.PLAIN; + value = plainValue(source, _onError); + break; + case 'single-quoted-scalar': + _type = Scalar.Scalar.QUOTE_SINGLE; + value = singleQuotedValue(source, _onError); + break; + case 'double-quoted-scalar': + _type = Scalar.Scalar.QUOTE_DOUBLE; + value = doubleQuotedValue(source, _onError); + break; + /* istanbul ignore next should not happen */ + default: + onError(scalar, 'UNEXPECTED_TOKEN', `Expected a flow scalar value, but found: ${type}`); + return { + value: '', + type: null, + comment: '', + range: [offset, offset + source.length, offset + source.length] + }; + } + const valueEnd = offset + source.length; + const re = resolveEnd.resolveEnd(end, valueEnd, strict, onError); + return { + value, + type: _type, + comment: re.comment, + range: [offset, valueEnd, re.offset] + }; +} +function plainValue(source, onError) { + let badChar = ''; + switch (source[0]) { + /* istanbul ignore next should not happen */ + case '\t': + badChar = 'a tab character'; + break; + case ',': + badChar = 'flow indicator character ,'; + break; + case '%': + badChar = 'directive indicator character %'; + break; + case '|': + case '>': { + badChar = `block scalar indicator ${source[0]}`; + break; + } + case '@': + case '`': { + badChar = `reserved character ${source[0]}`; + break; + } + } + if (badChar) + onError(0, 'BAD_SCALAR_START', `Plain value cannot start with ${badChar}`); + return unfoldLines(source); +} +function singleQuotedValue(source, onError) { + if (source[source.length - 1] !== "'" || source.length === 1) + onError(source.length, 'MISSING_CHAR', "Missing closing 'quote"); + return unfoldLines(source.slice(1, -1)).replace(/''/g, "'"); +} +function unfoldLines(source) { + const line = /(.*?)\r?\n/sy; + let match = line.exec(source); + if (!match) + return source; + /** + * The negative lookbehinds in these RegExps are to + * prevent causing a polynomial search time in certain cases. + * + * The try-catch is for Safari < 16.4 and other old browsers: + * https://caniuse.com/js-regexp-lookbehind + */ + let trimEnd, trimBoth; + try { + trimEnd = new RegExp('(? wsStart ? source.slice(wsStart, i + 1) : ch; + } + else { + res += ch; + } + } + if (source[source.length - 1] !== '"' || source.length === 1) + onError(source.length, 'MISSING_CHAR', 'Missing closing "quote'); + return res; +} +/** + * Fold a single newline into a space, multiple newlines to N - 1 newlines. + * Presumes `source[offset] === '\n'` + */ +function foldNewline(source, offset) { + let fold = ''; + let ch = source[offset + 1]; + while (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { + if (ch === '\r' && source[offset + 2] !== '\n') + break; + if (ch === '\n') + fold += '\n'; + offset += 1; + ch = source[offset + 1]; + } + if (!fold) + fold = ' '; + return { fold, offset }; +} +const escapeCodes = { + '0': '\0', // null character + a: '\x07', // bell character + b: '\b', // backspace + e: '\x1b', // escape character + f: '\f', // form feed + n: '\n', // line feed + r: '\r', // carriage return + t: '\t', // horizontal tab + v: '\v', // vertical tab + N: '\u0085', // Unicode next line + _: '\u00a0', // Unicode non-breaking space + L: '\u2028', // Unicode line separator + P: '\u2029', // Unicode paragraph separator + ' ': ' ', + '"': '"', + '/': '/', + '\\': '\\', + '\t': '\t' +}; +function parseCharCode(source, offset, length, onError) { + const cc = source.substr(offset, length); + const ok = cc.length === length && /^[0-9a-fA-F]+$/.test(cc); + const code = ok ? parseInt(cc, 16) : NaN; + try { + return String.fromCodePoint(code); + } + catch { + const raw = source.substr(offset - 2, length + 2); + onError(offset - 2, 'BAD_DQ_ESCAPE', `Invalid escape sequence ${raw}`); + return raw; + } +} + +exports.resolveFlowScalar = resolveFlowScalar; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-props.js b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-props.js new file mode 100644 index 00000000..a3598c89 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/resolve-props.js @@ -0,0 +1,148 @@ +'use strict'; + +function resolveProps(tokens, { flow, indicator, next, offset, onError, parentIndent, startOnNewline }) { + let spaceBefore = false; + let atNewline = startOnNewline; + let hasSpace = startOnNewline; + let comment = ''; + let commentSep = ''; + let hasNewline = false; + let reqSpace = false; + let tab = null; + let anchor = null; + let tag = null; + let newlineAfterProp = null; + let comma = null; + let found = null; + let start = null; + for (const token of tokens) { + if (reqSpace) { + if (token.type !== 'space' && + token.type !== 'newline' && + token.type !== 'comma') + onError(token.offset, 'MISSING_CHAR', 'Tags and anchors must be separated from the next token by white space'); + reqSpace = false; + } + if (tab) { + if (atNewline && token.type !== 'comment' && token.type !== 'newline') { + onError(tab, 'TAB_AS_INDENT', 'Tabs are not allowed as indentation'); + } + tab = null; + } + switch (token.type) { + case 'space': + // At the doc level, tabs at line start may be parsed + // as leading white space rather than indentation. + // In a flow collection, only the parser handles indent. + if (!flow && + (indicator !== 'doc-start' || next?.type !== 'flow-collection') && + token.source.includes('\t')) { + tab = token; + } + hasSpace = true; + break; + case 'comment': { + if (!hasSpace) + onError(token, 'MISSING_CHAR', 'Comments must be separated from other tokens by white space characters'); + const cb = token.source.substring(1) || ' '; + if (!comment) + comment = cb; + else + comment += commentSep + cb; + commentSep = ''; + atNewline = false; + break; + } + case 'newline': + if (atNewline) { + if (comment) + comment += token.source; + else if (!found || indicator !== 'seq-item-ind') + spaceBefore = true; + } + else + commentSep += token.source; + atNewline = true; + hasNewline = true; + if (anchor || tag) + newlineAfterProp = token; + hasSpace = true; + break; + case 'anchor': + if (anchor) + onError(token, 'MULTIPLE_ANCHORS', 'A node can have at most one anchor'); + if (token.source.endsWith(':')) + onError(token.offset + token.source.length - 1, 'BAD_ALIAS', 'Anchor ending in : is ambiguous', true); + anchor = token; + start ?? (start = token.offset); + atNewline = false; + hasSpace = false; + reqSpace = true; + break; + case 'tag': { + if (tag) + onError(token, 'MULTIPLE_TAGS', 'A node can have at most one tag'); + tag = token; + start ?? (start = token.offset); + atNewline = false; + hasSpace = false; + reqSpace = true; + break; + } + case indicator: + // Could here handle preceding comments differently + if (anchor || tag) + onError(token, 'BAD_PROP_ORDER', `Anchors and tags must be after the ${token.source} indicator`); + if (found) + onError(token, 'UNEXPECTED_TOKEN', `Unexpected ${token.source} in ${flow ?? 'collection'}`); + found = token; + atNewline = + indicator === 'seq-item-ind' || indicator === 'explicit-key-ind'; + hasSpace = false; + break; + case 'comma': + if (flow) { + if (comma) + onError(token, 'UNEXPECTED_TOKEN', `Unexpected , in ${flow}`); + comma = token; + atNewline = false; + hasSpace = false; + break; + } + // else fallthrough + default: + onError(token, 'UNEXPECTED_TOKEN', `Unexpected ${token.type} token`); + atNewline = false; + hasSpace = false; + } + } + const last = tokens[tokens.length - 1]; + const end = last ? last.offset + last.source.length : offset; + if (reqSpace && + next && + next.type !== 'space' && + next.type !== 'newline' && + next.type !== 'comma' && + (next.type !== 'scalar' || next.source !== '')) { + onError(next.offset, 'MISSING_CHAR', 'Tags and anchors must be separated from the next token by white space'); + } + if (tab && + ((atNewline && tab.indent <= parentIndent) || + next?.type === 'block-map' || + next?.type === 'block-seq')) + onError(tab, 'TAB_AS_INDENT', 'Tabs are not allowed as indentation'); + return { + comma, + found, + spaceBefore, + comment, + hasNewline, + anchor, + tag, + newlineAfterProp, + end, + start: start ?? end + }; +} + +exports.resolveProps = resolveProps; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/util-contains-newline.js b/bundled-addons/pithos/vendor/yaml/lib/compose/util-contains-newline.js new file mode 100644 index 00000000..e7aa82d8 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/util-contains-newline.js @@ -0,0 +1,36 @@ +'use strict'; + +function containsNewline(key) { + if (!key) + return null; + switch (key.type) { + case 'alias': + case 'scalar': + case 'double-quoted-scalar': + case 'single-quoted-scalar': + if (key.source.includes('\n')) + return true; + if (key.end) + for (const st of key.end) + if (st.type === 'newline') + return true; + return false; + case 'flow-collection': + for (const it of key.items) { + for (const st of it.start) + if (st.type === 'newline') + return true; + if (it.sep) + for (const st of it.sep) + if (st.type === 'newline') + return true; + if (containsNewline(it.key) || containsNewline(it.value)) + return true; + } + return false; + default: + return true; + } +} + +exports.containsNewline = containsNewline; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/util-empty-scalar-position.js b/bundled-addons/pithos/vendor/yaml/lib/compose/util-empty-scalar-position.js new file mode 100644 index 00000000..50fedb17 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/util-empty-scalar-position.js @@ -0,0 +1,28 @@ +'use strict'; + +function emptyScalarPosition(offset, before, pos) { + if (before) { + pos ?? (pos = before.length); + for (let i = pos - 1; i >= 0; --i) { + let st = before[i]; + switch (st.type) { + case 'space': + case 'comment': + case 'newline': + offset -= st.source.length; + continue; + } + // Technically, an empty scalar is immediately after the last non-empty + // node, but it's more useful to place it after any whitespace. + st = before[++i]; + while (st?.type === 'space') { + offset += st.source.length; + st = before[++i]; + } + break; + } + } + return offset; +} + +exports.emptyScalarPosition = emptyScalarPosition; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/util-flow-indent-check.js b/bundled-addons/pithos/vendor/yaml/lib/compose/util-flow-indent-check.js new file mode 100644 index 00000000..1e6b06f8 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/util-flow-indent-check.js @@ -0,0 +1,17 @@ +'use strict'; + +var utilContainsNewline = require('./util-contains-newline.js'); + +function flowIndentCheck(indent, fc, onError) { + if (fc?.type === 'flow-collection') { + const end = fc.end[0]; + if (end.indent === indent && + (end.source === ']' || end.source === '}') && + utilContainsNewline.containsNewline(fc)) { + const msg = 'Flow end indicator should be more indented than parent'; + onError(end, 'BAD_INDENT', msg, true); + } + } +} + +exports.flowIndentCheck = flowIndentCheck; diff --git a/bundled-addons/pithos/vendor/yaml/lib/compose/util-map-includes.js b/bundled-addons/pithos/vendor/yaml/lib/compose/util-map-includes.js new file mode 100644 index 00000000..ebd7a2da --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/compose/util-map-includes.js @@ -0,0 +1,15 @@ +'use strict'; + +var identity = require('../nodes/identity.js'); + +function mapIncludes(ctx, items, search) { + const { uniqueKeys } = ctx.options; + if (uniqueKeys === false) + return false; + const isEqual = typeof uniqueKeys === 'function' + ? uniqueKeys + : (a, b) => a === b || (identity.isScalar(a) && identity.isScalar(b) && a.value === b.value); + return items.some(pair => isEqual(pair.key, search)); +} + +exports.mapIncludes = mapIncludes; diff --git a/bundled-addons/pithos/vendor/yaml/lib/doc/Document.js b/bundled-addons/pithos/vendor/yaml/lib/doc/Document.js new file mode 100644 index 00000000..a9530883 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/doc/Document.js @@ -0,0 +1,337 @@ +'use strict'; + +var Alias = require('../nodes/Alias.js'); +var Collection = require('../nodes/Collection.js'); +var identity = require('../nodes/identity.js'); +var Pair = require('../nodes/Pair.js'); +var toJS = require('../nodes/toJS.js'); +var Schema = require('../schema/Schema.js'); +var stringifyDocument = require('../stringify/stringifyDocument.js'); +var anchors = require('./anchors.js'); +var applyReviver = require('./applyReviver.js'); +var createNode = require('./createNode.js'); +var directives = require('./directives.js'); + +class Document { + constructor(value, replacer, options) { + /** A comment before this Document */ + this.commentBefore = null; + /** A comment immediately after this Document */ + this.comment = null; + /** Errors encountered during parsing. */ + this.errors = []; + /** Warnings encountered during parsing. */ + this.warnings = []; + Object.defineProperty(this, identity.NODE_TYPE, { value: identity.DOC }); + let _replacer = null; + if (typeof replacer === 'function' || Array.isArray(replacer)) { + _replacer = replacer; + } + else if (options === undefined && replacer) { + options = replacer; + replacer = undefined; + } + const opt = Object.assign({ + intAsBigInt: false, + keepSourceTokens: false, + logLevel: 'warn', + prettyErrors: true, + strict: true, + stringKeys: false, + uniqueKeys: true, + version: '1.2' + }, options); + this.options = opt; + let { version } = opt; + if (options?._directives) { + this.directives = options._directives.atDocument(); + if (this.directives.yaml.explicit) + version = this.directives.yaml.version; + } + else + this.directives = new directives.Directives({ version }); + this.setSchema(version, options); + // @ts-expect-error We can't really know that this matches Contents. + this.contents = + value === undefined ? null : this.createNode(value, _replacer, options); + } + /** + * Create a deep copy of this Document and its contents. + * + * Custom Node values that inherit from `Object` still refer to their original instances. + */ + clone() { + const copy = Object.create(Document.prototype, { + [identity.NODE_TYPE]: { value: identity.DOC } + }); + copy.commentBefore = this.commentBefore; + copy.comment = this.comment; + copy.errors = this.errors.slice(); + copy.warnings = this.warnings.slice(); + copy.options = Object.assign({}, this.options); + if (this.directives) + copy.directives = this.directives.clone(); + copy.schema = this.schema.clone(); + // @ts-expect-error We can't really know that this matches Contents. + copy.contents = identity.isNode(this.contents) + ? this.contents.clone(copy.schema) + : this.contents; + if (this.range) + copy.range = this.range.slice(); + return copy; + } + /** Adds a value to the document. */ + add(value) { + if (assertCollection(this.contents)) + this.contents.add(value); + } + /** Adds a value to the document. */ + addIn(path, value) { + if (assertCollection(this.contents)) + this.contents.addIn(path, value); + } + /** + * Create a new `Alias` node, ensuring that the target `node` has the required anchor. + * + * If `node` already has an anchor, `name` is ignored. + * Otherwise, the `node.anchor` value will be set to `name`, + * or if an anchor with that name is already present in the document, + * `name` will be used as a prefix for a new unique anchor. + * If `name` is undefined, the generated anchor will use 'a' as a prefix. + */ + createAlias(node, name) { + if (!node.anchor) { + const prev = anchors.anchorNames(this); + node.anchor = + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + !name || prev.has(name) ? anchors.findNewAnchor(name || 'a', prev) : name; + } + return new Alias.Alias(node.anchor); + } + createNode(value, replacer, options) { + let _replacer = undefined; + if (typeof replacer === 'function') { + value = replacer.call({ '': value }, '', value); + _replacer = replacer; + } + else if (Array.isArray(replacer)) { + const keyToStr = (v) => typeof v === 'number' || v instanceof String || v instanceof Number; + const asStr = replacer.filter(keyToStr).map(String); + if (asStr.length > 0) + replacer = replacer.concat(asStr); + _replacer = replacer; + } + else if (options === undefined && replacer) { + options = replacer; + replacer = undefined; + } + const { aliasDuplicateObjects, anchorPrefix, flow, keepUndefined, onTagObj, tag } = options ?? {}; + const { onAnchor, setAnchors, sourceObjects } = anchors.createNodeAnchors(this, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + anchorPrefix || 'a'); + const ctx = { + aliasDuplicateObjects: aliasDuplicateObjects ?? true, + keepUndefined: keepUndefined ?? false, + onAnchor, + onTagObj, + replacer: _replacer, + schema: this.schema, + sourceObjects + }; + const node = createNode.createNode(value, tag, ctx); + if (flow && identity.isCollection(node)) + node.flow = true; + setAnchors(); + return node; + } + /** + * Convert a key and a value into a `Pair` using the current schema, + * recursively wrapping all values as `Scalar` or `Collection` nodes. + */ + createPair(key, value, options = {}) { + const k = this.createNode(key, null, options); + const v = this.createNode(value, null, options); + return new Pair.Pair(k, v); + } + /** + * Removes a value from the document. + * @returns `true` if the item was found and removed. + */ + delete(key) { + return assertCollection(this.contents) ? this.contents.delete(key) : false; + } + /** + * Removes a value from the document. + * @returns `true` if the item was found and removed. + */ + deleteIn(path) { + if (Collection.isEmptyPath(path)) { + if (this.contents == null) + return false; + // @ts-expect-error Presumed impossible if Strict extends false + this.contents = null; + return true; + } + return assertCollection(this.contents) + ? this.contents.deleteIn(path) + : false; + } + /** + * Returns item at `key`, or `undefined` if not found. By default unwraps + * scalar values from their surrounding node; to disable set `keepScalar` to + * `true` (collections are always returned intact). + */ + get(key, keepScalar) { + return identity.isCollection(this.contents) + ? this.contents.get(key, keepScalar) + : undefined; + } + /** + * Returns item at `path`, or `undefined` if not found. By default unwraps + * scalar values from their surrounding node; to disable set `keepScalar` to + * `true` (collections are always returned intact). + */ + getIn(path, keepScalar) { + if (Collection.isEmptyPath(path)) + return !keepScalar && identity.isScalar(this.contents) + ? this.contents.value + : this.contents; + return identity.isCollection(this.contents) + ? this.contents.getIn(path, keepScalar) + : undefined; + } + /** + * Checks if the document includes a value with the key `key`. + */ + has(key) { + return identity.isCollection(this.contents) ? this.contents.has(key) : false; + } + /** + * Checks if the document includes a value at `path`. + */ + hasIn(path) { + if (Collection.isEmptyPath(path)) + return this.contents !== undefined; + return identity.isCollection(this.contents) ? this.contents.hasIn(path) : false; + } + /** + * Sets a value in this document. For `!!set`, `value` needs to be a + * boolean to add/remove the item from the set. + */ + set(key, value) { + if (this.contents == null) { + // @ts-expect-error We can't really know that this matches Contents. + this.contents = Collection.collectionFromPath(this.schema, [key], value); + } + else if (assertCollection(this.contents)) { + this.contents.set(key, value); + } + } + /** + * Sets a value in this document. For `!!set`, `value` needs to be a + * boolean to add/remove the item from the set. + */ + setIn(path, value) { + if (Collection.isEmptyPath(path)) { + // @ts-expect-error We can't really know that this matches Contents. + this.contents = value; + } + else if (this.contents == null) { + // @ts-expect-error We can't really know that this matches Contents. + this.contents = Collection.collectionFromPath(this.schema, Array.from(path), value); + } + else if (assertCollection(this.contents)) { + this.contents.setIn(path, value); + } + } + /** + * Change the YAML version and schema used by the document. + * A `null` version disables support for directives, explicit tags, anchors, and aliases. + * It also requires the `schema` option to be given as a `Schema` instance value. + * + * Overrides all previously set schema options. + */ + setSchema(version, options = {}) { + if (typeof version === 'number') + version = String(version); + let opt; + switch (version) { + case '1.1': + if (this.directives) + this.directives.yaml.version = '1.1'; + else + this.directives = new directives.Directives({ version: '1.1' }); + opt = { resolveKnownTags: false, schema: 'yaml-1.1' }; + break; + case '1.2': + case 'next': + if (this.directives) + this.directives.yaml.version = version; + else + this.directives = new directives.Directives({ version }); + opt = { resolveKnownTags: true, schema: 'core' }; + break; + case null: + if (this.directives) + delete this.directives; + opt = null; + break; + default: { + const sv = JSON.stringify(version); + throw new Error(`Expected '1.1', '1.2' or null as first argument, but found: ${sv}`); + } + } + // Not using `instanceof Schema` to allow for duck typing + if (options.schema instanceof Object) + this.schema = options.schema; + else if (opt) + this.schema = new Schema.Schema(Object.assign(opt, options)); + else + throw new Error(`With a null YAML version, the { schema: Schema } option is required`); + } + // json & jsonArg are only used from toJSON() + toJS({ json, jsonArg, mapAsMap, maxAliasCount, onAnchor, reviver } = {}) { + const ctx = { + anchors: new Map(), + doc: this, + keep: !json, + mapAsMap: mapAsMap === true, + mapKeyWarned: false, + maxAliasCount: typeof maxAliasCount === 'number' ? maxAliasCount : 100 + }; + const res = toJS.toJS(this.contents, jsonArg ?? '', ctx); + if (typeof onAnchor === 'function') + for (const { count, res } of ctx.anchors.values()) + onAnchor(res, count); + return typeof reviver === 'function' + ? applyReviver.applyReviver(reviver, { '': res }, '', res) + : res; + } + /** + * A JSON representation of the document `contents`. + * + * @param jsonArg Used by `JSON.stringify` to indicate the array index or + * property name. + */ + toJSON(jsonArg, onAnchor) { + return this.toJS({ json: true, jsonArg, mapAsMap: false, onAnchor }); + } + /** A YAML representation of the document. */ + toString(options = {}) { + if (this.errors.length > 0) + throw new Error('Document with errors cannot be stringified'); + if ('indent' in options && + (!Number.isInteger(options.indent) || Number(options.indent) <= 0)) { + const s = JSON.stringify(options.indent); + throw new Error(`"indent" option must be a positive integer, not ${s}`); + } + return stringifyDocument.stringifyDocument(this, options); + } +} +function assertCollection(contents) { + if (identity.isCollection(contents)) + return true; + throw new Error('Expected a YAML collection as document contents'); +} + +exports.Document = Document; diff --git a/bundled-addons/pithos/vendor/yaml/lib/doc/anchors.js b/bundled-addons/pithos/vendor/yaml/lib/doc/anchors.js new file mode 100644 index 00000000..68613fa7 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/doc/anchors.js @@ -0,0 +1,76 @@ +'use strict'; + +var identity = require('../nodes/identity.js'); +var visit = require('../visit.js'); + +/** + * Verify that the input string is a valid anchor. + * + * Will throw on errors. + */ +function anchorIsValid(anchor) { + if (/[\x00-\x19\s,[\]{}]/.test(anchor)) { + const sa = JSON.stringify(anchor); + const msg = `Anchor must not contain whitespace or control characters: ${sa}`; + throw new Error(msg); + } + return true; +} +function anchorNames(root) { + const anchors = new Set(); + visit.visit(root, { + Value(_key, node) { + if (node.anchor) + anchors.add(node.anchor); + } + }); + return anchors; +} +/** Find a new anchor name with the given `prefix` and a one-indexed suffix. */ +function findNewAnchor(prefix, exclude) { + for (let i = 1; true; ++i) { + const name = `${prefix}${i}`; + if (!exclude.has(name)) + return name; + } +} +function createNodeAnchors(doc, prefix) { + const aliasObjects = []; + const sourceObjects = new Map(); + let prevAnchors = null; + return { + onAnchor: (source) => { + aliasObjects.push(source); + prevAnchors ?? (prevAnchors = anchorNames(doc)); + const anchor = findNewAnchor(prefix, prevAnchors); + prevAnchors.add(anchor); + return anchor; + }, + /** + * With circular references, the source node is only resolved after all + * of its child nodes are. This is why anchors are set only after all of + * the nodes have been created. + */ + setAnchors: () => { + for (const source of aliasObjects) { + const ref = sourceObjects.get(source); + if (typeof ref === 'object' && + ref.anchor && + (identity.isScalar(ref.node) || identity.isCollection(ref.node))) { + ref.node.anchor = ref.anchor; + } + else { + const error = new Error('Failed to resolve repeated object (this should not happen)'); + error.source = source; + throw error; + } + } + }, + sourceObjects + }; +} + +exports.anchorIsValid = anchorIsValid; +exports.anchorNames = anchorNames; +exports.createNodeAnchors = createNodeAnchors; +exports.findNewAnchor = findNewAnchor; diff --git a/bundled-addons/pithos/vendor/yaml/lib/doc/applyReviver.js b/bundled-addons/pithos/vendor/yaml/lib/doc/applyReviver.js new file mode 100644 index 00000000..bfd0ba86 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/doc/applyReviver.js @@ -0,0 +1,57 @@ +'use strict'; + +/** + * Applies the JSON.parse reviver algorithm as defined in the ECMA-262 spec, + * in section 24.5.1.1 "Runtime Semantics: InternalizeJSONProperty" of the + * 2021 edition: https://tc39.es/ecma262/#sec-json.parse + * + * Includes extensions for handling Map and Set objects. + */ +function applyReviver(reviver, obj, key, val) { + if (val && typeof val === 'object') { + if (Array.isArray(val)) { + for (let i = 0, len = val.length; i < len; ++i) { + const v0 = val[i]; + const v1 = applyReviver(reviver, val, String(i), v0); + // eslint-disable-next-line @typescript-eslint/no-array-delete + if (v1 === undefined) + delete val[i]; + else if (v1 !== v0) + val[i] = v1; + } + } + else if (val instanceof Map) { + for (const k of Array.from(val.keys())) { + const v0 = val.get(k); + const v1 = applyReviver(reviver, val, k, v0); + if (v1 === undefined) + val.delete(k); + else if (v1 !== v0) + val.set(k, v1); + } + } + else if (val instanceof Set) { + for (const v0 of Array.from(val)) { + const v1 = applyReviver(reviver, val, v0, v0); + if (v1 === undefined) + val.delete(v0); + else if (v1 !== v0) { + val.delete(v0); + val.add(v1); + } + } + } + else { + for (const [k, v0] of Object.entries(val)) { + const v1 = applyReviver(reviver, val, k, v0); + if (v1 === undefined) + delete val[k]; + else if (v1 !== v0) + val[k] = v1; + } + } + } + return reviver.call(obj, key, val); +} + +exports.applyReviver = applyReviver; diff --git a/bundled-addons/pithos/vendor/yaml/lib/doc/createNode.js b/bundled-addons/pithos/vendor/yaml/lib/doc/createNode.js new file mode 100644 index 00000000..53522c6b --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/doc/createNode.js @@ -0,0 +1,90 @@ +'use strict'; + +var Alias = require('../nodes/Alias.js'); +var identity = require('../nodes/identity.js'); +var Scalar = require('../nodes/Scalar.js'); + +const defaultTagPrefix = 'tag:yaml.org,2002:'; +function findTagObject(value, tagName, tags) { + if (tagName) { + const match = tags.filter(t => t.tag === tagName); + const tagObj = match.find(t => !t.format) ?? match[0]; + if (!tagObj) + throw new Error(`Tag ${tagName} not found`); + return tagObj; + } + return tags.find(t => t.identify?.(value) && !t.format); +} +function createNode(value, tagName, ctx) { + if (identity.isDocument(value)) + value = value.contents; + if (identity.isNode(value)) + return value; + if (identity.isPair(value)) { + const map = ctx.schema[identity.MAP].createNode?.(ctx.schema, null, ctx); + map.items.push(value); + return map; + } + if (value instanceof String || + value instanceof Number || + value instanceof Boolean || + (typeof BigInt !== 'undefined' && value instanceof BigInt) // not supported everywhere + ) { + // https://tc39.es/ecma262/#sec-serializejsonproperty + value = value.valueOf(); + } + const { aliasDuplicateObjects, onAnchor, onTagObj, schema, sourceObjects } = ctx; + // Detect duplicate references to the same object & use Alias nodes for all + // after first. The `ref` wrapper allows for circular references to resolve. + let ref = undefined; + if (aliasDuplicateObjects && value && typeof value === 'object') { + ref = sourceObjects.get(value); + if (ref) { + ref.anchor ?? (ref.anchor = onAnchor(value)); + return new Alias.Alias(ref.anchor); + } + else { + ref = { anchor: null, node: null }; + sourceObjects.set(value, ref); + } + } + if (tagName?.startsWith('!!')) + tagName = defaultTagPrefix + tagName.slice(2); + let tagObj = findTagObject(value, tagName, schema.tags); + if (!tagObj) { + if (value && typeof value.toJSON === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + value = value.toJSON(); + } + if (!value || typeof value !== 'object') { + const node = new Scalar.Scalar(value); + if (ref) + ref.node = node; + return node; + } + tagObj = + value instanceof Map + ? schema[identity.MAP] + : Symbol.iterator in Object(value) + ? schema[identity.SEQ] + : schema[identity.MAP]; + } + if (onTagObj) { + onTagObj(tagObj); + delete ctx.onTagObj; + } + const node = tagObj?.createNode + ? tagObj.createNode(ctx.schema, value, ctx) + : typeof tagObj?.nodeClass?.from === 'function' + ? tagObj.nodeClass.from(ctx.schema, value, ctx) + : new Scalar.Scalar(value); + if (tagName) + node.tag = tagName; + else if (!tagObj.default) + node.tag = tagObj.tag; + if (ref) + ref.node = node; + return node; +} + +exports.createNode = createNode; diff --git a/bundled-addons/pithos/vendor/yaml/lib/doc/directives.js b/bundled-addons/pithos/vendor/yaml/lib/doc/directives.js new file mode 100644 index 00000000..e13b10e3 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/doc/directives.js @@ -0,0 +1,178 @@ +'use strict'; + +var identity = require('../nodes/identity.js'); +var visit = require('../visit.js'); + +const escapeChars = { + '!': '%21', + ',': '%2C', + '[': '%5B', + ']': '%5D', + '{': '%7B', + '}': '%7D' +}; +const escapeTagName = (tn) => tn.replace(/[!,[\]{}]/g, ch => escapeChars[ch]); +class Directives { + constructor(yaml, tags) { + /** + * The directives-end/doc-start marker `---`. If `null`, a marker may still be + * included in the document's stringified representation. + */ + this.docStart = null; + /** The doc-end marker `...`. */ + this.docEnd = false; + this.yaml = Object.assign({}, Directives.defaultYaml, yaml); + this.tags = Object.assign({}, Directives.defaultTags, tags); + } + clone() { + const copy = new Directives(this.yaml, this.tags); + copy.docStart = this.docStart; + return copy; + } + /** + * During parsing, get a Directives instance for the current document and + * update the stream state according to the current version's spec. + */ + atDocument() { + const res = new Directives(this.yaml, this.tags); + switch (this.yaml.version) { + case '1.1': + this.atNextDocument = true; + break; + case '1.2': + this.atNextDocument = false; + this.yaml = { + explicit: Directives.defaultYaml.explicit, + version: '1.2' + }; + this.tags = Object.assign({}, Directives.defaultTags); + break; + } + return res; + } + /** + * @param onError - May be called even if the action was successful + * @returns `true` on success + */ + add(line, onError) { + if (this.atNextDocument) { + this.yaml = { explicit: Directives.defaultYaml.explicit, version: '1.1' }; + this.tags = Object.assign({}, Directives.defaultTags); + this.atNextDocument = false; + } + const parts = line.trim().split(/[ \t]+/); + const name = parts.shift(); + switch (name) { + case '%TAG': { + if (parts.length !== 2) { + onError(0, '%TAG directive should contain exactly two parts'); + if (parts.length < 2) + return false; + } + const [handle, prefix] = parts; + this.tags[handle] = prefix; + return true; + } + case '%YAML': { + this.yaml.explicit = true; + if (parts.length !== 1) { + onError(0, '%YAML directive should contain exactly one part'); + return false; + } + const [version] = parts; + if (version === '1.1' || version === '1.2') { + this.yaml.version = version; + return true; + } + else { + const isValid = /^\d+\.\d+$/.test(version); + onError(6, `Unsupported YAML version ${version}`, isValid); + return false; + } + } + default: + onError(0, `Unknown directive ${name}`, true); + return false; + } + } + /** + * Resolves a tag, matching handles to those defined in %TAG directives. + * + * @returns Resolved tag, which may also be the non-specific tag `'!'` or a + * `'!local'` tag, or `null` if unresolvable. + */ + tagName(source, onError) { + if (source === '!') + return '!'; // non-specific tag + if (source[0] !== '!') { + onError(`Not a valid tag: ${source}`); + return null; + } + if (source[1] === '<') { + const verbatim = source.slice(2, -1); + if (verbatim === '!' || verbatim === '!!') { + onError(`Verbatim tags aren't resolved, so ${source} is invalid.`); + return null; + } + if (source[source.length - 1] !== '>') + onError('Verbatim tags must end with a >'); + return verbatim; + } + const [, handle, suffix] = source.match(/^(.*!)([^!]*)$/s); + if (!suffix) + onError(`The ${source} tag has no suffix`); + const prefix = this.tags[handle]; + if (prefix) { + try { + return prefix + decodeURIComponent(suffix); + } + catch (error) { + onError(String(error)); + return null; + } + } + if (handle === '!') + return source; // local tag + onError(`Could not resolve tag: ${source}`); + return null; + } + /** + * Given a fully resolved tag, returns its printable string form, + * taking into account current tag prefixes and defaults. + */ + tagString(tag) { + for (const [handle, prefix] of Object.entries(this.tags)) { + if (tag.startsWith(prefix)) + return handle + escapeTagName(tag.substring(prefix.length)); + } + return tag[0] === '!' ? tag : `!<${tag}>`; + } + toString(doc) { + const lines = this.yaml.explicit + ? [`%YAML ${this.yaml.version || '1.2'}`] + : []; + const tagEntries = Object.entries(this.tags); + let tagNames; + if (doc && tagEntries.length > 0 && identity.isNode(doc.contents)) { + const tags = {}; + visit.visit(doc.contents, (_key, node) => { + if (identity.isNode(node) && node.tag) + tags[node.tag] = true; + }); + tagNames = Object.keys(tags); + } + else + tagNames = []; + for (const [handle, prefix] of tagEntries) { + if (handle === '!!' && prefix === 'tag:yaml.org,2002:') + continue; + if (!doc || tagNames.some(tn => tn.startsWith(prefix))) + lines.push(`%TAG ${handle} ${prefix}`); + } + return lines.join('\n'); + } +} +Directives.defaultYaml = { explicit: false, version: '1.2' }; +Directives.defaultTags = { '!!': 'tag:yaml.org,2002:' }; + +exports.Directives = Directives; diff --git a/bundled-addons/pithos/vendor/yaml/lib/errors.js b/bundled-addons/pithos/vendor/yaml/lib/errors.js new file mode 100644 index 00000000..358c7ed4 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/errors.js @@ -0,0 +1,62 @@ +'use strict'; + +class YAMLError extends Error { + constructor(name, pos, code, message) { + super(); + this.name = name; + this.code = code; + this.message = message; + this.pos = pos; + } +} +class YAMLParseError extends YAMLError { + constructor(pos, code, message) { + super('YAMLParseError', pos, code, message); + } +} +class YAMLWarning extends YAMLError { + constructor(pos, code, message) { + super('YAMLWarning', pos, code, message); + } +} +const prettifyError = (src, lc) => (error) => { + if (error.pos[0] === -1) + return; + error.linePos = error.pos.map(pos => lc.linePos(pos)); + const { line, col } = error.linePos[0]; + error.message += ` at line ${line}, column ${col}`; + let ci = col - 1; + let lineStr = src + .substring(lc.lineStarts[line - 1], lc.lineStarts[line]) + .replace(/[\n\r]+$/, ''); + // Trim to max 80 chars, keeping col position near the middle + if (ci >= 60 && lineStr.length > 80) { + const trimStart = Math.min(ci - 39, lineStr.length - 79); + lineStr = '…' + lineStr.substring(trimStart); + ci -= trimStart - 1; + } + if (lineStr.length > 80) + lineStr = lineStr.substring(0, 79) + '…'; + // Include previous line in context if pointing at line start + if (line > 1 && /^ *$/.test(lineStr.substring(0, ci))) { + // Regexp won't match if start is trimmed + let prev = src.substring(lc.lineStarts[line - 2], lc.lineStarts[line - 1]); + if (prev.length > 80) + prev = prev.substring(0, 79) + '…\n'; + lineStr = prev + lineStr; + } + if (/[^ ]/.test(lineStr)) { + let count = 1; + const end = error.linePos[1]; + if (end?.line === line && end.col > col) { + count = Math.max(1, Math.min(end.col - col, 80 - ci)); + } + const pointer = ' '.repeat(ci) + '^'.repeat(count); + error.message += `:\n\n${lineStr}\n${pointer}\n`; + } +}; + +exports.YAMLError = YAMLError; +exports.YAMLParseError = YAMLParseError; +exports.YAMLWarning = YAMLWarning; +exports.prettifyError = prettifyError; diff --git a/bundled-addons/pithos/vendor/yaml/lib/index.js b/bundled-addons/pithos/vendor/yaml/lib/index.js new file mode 100644 index 00000000..18c0cb61 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/index.js @@ -0,0 +1,50 @@ +'use strict'; + +var composer = require('./compose/composer.js'); +var Document = require('./doc/Document.js'); +var Schema = require('./schema/Schema.js'); +var errors = require('./errors.js'); +var Alias = require('./nodes/Alias.js'); +var identity = require('./nodes/identity.js'); +var Pair = require('./nodes/Pair.js'); +var Scalar = require('./nodes/Scalar.js'); +var YAMLMap = require('./nodes/YAMLMap.js'); +var YAMLSeq = require('./nodes/YAMLSeq.js'); +var cst = require('./parse/cst.js'); +var lexer = require('./parse/lexer.js'); +var lineCounter = require('./parse/line-counter.js'); +var parser = require('./parse/parser.js'); +var publicApi = require('./public-api.js'); +var visit = require('./visit.js'); + + + +exports.Composer = composer.Composer; +exports.Document = Document.Document; +exports.Schema = Schema.Schema; +exports.YAMLError = errors.YAMLError; +exports.YAMLParseError = errors.YAMLParseError; +exports.YAMLWarning = errors.YAMLWarning; +exports.Alias = Alias.Alias; +exports.isAlias = identity.isAlias; +exports.isCollection = identity.isCollection; +exports.isDocument = identity.isDocument; +exports.isMap = identity.isMap; +exports.isNode = identity.isNode; +exports.isPair = identity.isPair; +exports.isScalar = identity.isScalar; +exports.isSeq = identity.isSeq; +exports.Pair = Pair.Pair; +exports.Scalar = Scalar.Scalar; +exports.YAMLMap = YAMLMap.YAMLMap; +exports.YAMLSeq = YAMLSeq.YAMLSeq; +exports.CST = cst; +exports.Lexer = lexer.Lexer; +exports.LineCounter = lineCounter.LineCounter; +exports.Parser = parser.Parser; +exports.parse = publicApi.parse; +exports.parseAllDocuments = publicApi.parseAllDocuments; +exports.parseDocument = publicApi.parseDocument; +exports.stringify = publicApi.stringify; +exports.visit = visit.visit; +exports.visitAsync = visit.visitAsync; diff --git a/bundled-addons/pithos/vendor/yaml/lib/log.js b/bundled-addons/pithos/vendor/yaml/lib/log.js new file mode 100644 index 00000000..65f99508 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/log.js @@ -0,0 +1,19 @@ +'use strict'; + +var node_process = require('process'); + +function debug(logLevel, ...messages) { + if (logLevel === 'debug') + console.log(...messages); +} +function warn(logLevel, warning) { + if (logLevel === 'debug' || logLevel === 'warn') { + if (typeof node_process.emitWarning === 'function') + node_process.emitWarning(warning); + else + console.warn(warning); + } +} + +exports.debug = debug; +exports.warn = warn; diff --git a/bundled-addons/pithos/vendor/yaml/lib/nodes/Alias.js b/bundled-addons/pithos/vendor/yaml/lib/nodes/Alias.js new file mode 100644 index 00000000..357d1c93 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/nodes/Alias.js @@ -0,0 +1,120 @@ +'use strict'; + +var anchors = require('../doc/anchors.js'); +var visit = require('../visit.js'); +var identity = require('./identity.js'); +var Node = require('./Node.js'); +var toJS = require('./toJS.js'); + +class Alias extends Node.NodeBase { + constructor(source) { + super(identity.ALIAS); + this.source = source; + Object.defineProperty(this, 'tag', { + set() { + throw new Error('Alias nodes cannot have tags'); + } + }); + } + /** + * Resolve the value of this alias within `doc`, finding the last + * instance of the `source` anchor before this node. + */ + resolve(doc, ctx) { + if (ctx?.maxAliasCount === 0) + throw new ReferenceError('Alias resolution is disabled'); + let nodes; + if (ctx?.aliasResolveCache) { + nodes = ctx.aliasResolveCache; + } + else { + nodes = []; + visit.visit(doc, { + Node: (_key, node) => { + if (identity.isAlias(node) || identity.hasAnchor(node)) + nodes.push(node); + } + }); + if (ctx) + ctx.aliasResolveCache = nodes; + } + let found = undefined; + for (const node of nodes) { + if (node === this) + break; + if (node.anchor === this.source) + found = node; + } + if (found && ctx) { + const { anchors, doc, maxAliasCount } = ctx; + let data = anchors.get(found); + if (!data) { + // Resolve anchors for Node.prototype.toJS() + toJS.toJS(found, null, ctx); + data = anchors.get(found); + } + /* istanbul ignore if */ + if (data?.res === undefined) { + const msg = 'This should not happen: Alias anchor was not resolved?'; + throw new ReferenceError(msg); + } + if (maxAliasCount >= 0) { + data.count += 1; + if (data.aliasCount === 0) + data.aliasCount = getAliasCount(doc, found, anchors); + if (data.count * data.aliasCount > maxAliasCount) { + const msg = 'Excessive alias count indicates a resource exhaustion attack'; + throw new ReferenceError(msg); + } + } + } + return found; + } + toJSON(_arg, ctx) { + if (!ctx) + return { source: this.source }; + const source = this.resolve(ctx.doc, ctx); + if (!source) { + const msg = `Unresolved alias (the anchor must be set before the alias): ${this.source}`; + throw new ReferenceError(msg); + } + return ctx.anchors.get(source).res; + } + toString(ctx, _onComment, _onChompKeep) { + const src = `*${this.source}`; + if (ctx) { + anchors.anchorIsValid(this.source); + if (ctx.options.verifyAliasOrder && !ctx.anchors.has(this.source)) { + const msg = `Unresolved alias (the anchor must be set before the alias): ${this.source}`; + throw new Error(msg); + } + if (ctx.implicitKey) + return `${src} `; + } + return src; + } +} +function getAliasCount(doc, node, anchors) { + if (identity.isAlias(node)) { + const source = node.resolve(doc); + const anchor = anchors && source && anchors.get(source); + return anchor ? anchor.count * anchor.aliasCount : 0; + } + else if (identity.isCollection(node)) { + let count = 0; + for (const item of node.items) { + const c = getAliasCount(doc, item, anchors); + if (c > count) + count = c; + } + return count; + } + else if (identity.isPair(node)) { + const kc = getAliasCount(doc, node.key, anchors); + const vc = getAliasCount(doc, node.value, anchors); + return Math.max(kc, vc); + } + return 1; +} + +exports.Alias = Alias; diff --git a/bundled-addons/pithos/vendor/yaml/lib/nodes/Collection.js b/bundled-addons/pithos/vendor/yaml/lib/nodes/Collection.js new file mode 100644 index 00000000..bdf8cb4f --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/nodes/Collection.js @@ -0,0 +1,151 @@ +'use strict'; + +var createNode = require('../doc/createNode.js'); +var identity = require('./identity.js'); +var Node = require('./Node.js'); + +function collectionFromPath(schema, path, value) { + let v = value; + for (let i = path.length - 1; i >= 0; --i) { + const k = path[i]; + if (typeof k === 'number' && Number.isInteger(k) && k >= 0) { + const a = []; + a[k] = v; + v = a; + } + else { + v = new Map([[k, v]]); + } + } + return createNode.createNode(v, undefined, { + aliasDuplicateObjects: false, + keepUndefined: false, + onAnchor: () => { + throw new Error('This should not happen, please report a bug.'); + }, + schema, + sourceObjects: new Map() + }); +} +// Type guard is intentionally a little wrong so as to be more useful, +// as it does not cover untypable empty non-string iterables (e.g. []). +const isEmptyPath = (path) => path == null || + (typeof path === 'object' && !!path[Symbol.iterator]().next().done); +class Collection extends Node.NodeBase { + constructor(type, schema) { + super(type); + Object.defineProperty(this, 'schema', { + value: schema, + configurable: true, + enumerable: false, + writable: true + }); + } + /** + * Create a copy of this collection. + * + * @param schema - If defined, overwrites the original's schema + */ + clone(schema) { + const copy = Object.create(Object.getPrototypeOf(this), Object.getOwnPropertyDescriptors(this)); + if (schema) + copy.schema = schema; + copy.items = copy.items.map(it => identity.isNode(it) || identity.isPair(it) ? it.clone(schema) : it); + if (this.range) + copy.range = this.range.slice(); + return copy; + } + /** + * Adds a value to the collection. For `!!map` and `!!omap` the value must + * be a Pair instance or a `{ key, value }` object, which may not have a key + * that already exists in the map. + */ + addIn(path, value) { + if (isEmptyPath(path)) + this.add(value); + else { + const [key, ...rest] = path; + const node = this.get(key, true); + if (identity.isCollection(node)) + node.addIn(rest, value); + else if (node === undefined && this.schema) + this.set(key, collectionFromPath(this.schema, rest, value)); + else + throw new Error(`Expected YAML collection at ${key}. Remaining path: ${rest}`); + } + } + /** + * Removes a value from the collection. + * @returns `true` if the item was found and removed. + */ + deleteIn(path) { + const [key, ...rest] = path; + if (rest.length === 0) + return this.delete(key); + const node = this.get(key, true); + if (identity.isCollection(node)) + return node.deleteIn(rest); + else + throw new Error(`Expected YAML collection at ${key}. Remaining path: ${rest}`); + } + /** + * Returns item at `key`, or `undefined` if not found. By default unwraps + * scalar values from their surrounding node; to disable set `keepScalar` to + * `true` (collections are always returned intact). + */ + getIn(path, keepScalar) { + const [key, ...rest] = path; + const node = this.get(key, true); + if (rest.length === 0) + return !keepScalar && identity.isScalar(node) ? node.value : node; + else + return identity.isCollection(node) ? node.getIn(rest, keepScalar) : undefined; + } + hasAllNullValues(allowScalar) { + return this.items.every(node => { + if (!identity.isPair(node)) + return false; + const n = node.value; + return (n == null || + (allowScalar && + identity.isScalar(n) && + n.value == null && + !n.commentBefore && + !n.comment && + !n.tag)); + }); + } + /** + * Checks if the collection includes a value with the key `key`. + */ + hasIn(path) { + const [key, ...rest] = path; + if (rest.length === 0) + return this.has(key); + const node = this.get(key, true); + return identity.isCollection(node) ? node.hasIn(rest) : false; + } + /** + * Sets a value in this collection. For `!!set`, `value` needs to be a + * boolean to add/remove the item from the set. + */ + setIn(path, value) { + const [key, ...rest] = path; + if (rest.length === 0) { + this.set(key, value); + } + else { + const node = this.get(key, true); + if (identity.isCollection(node)) + node.setIn(rest, value); + else if (node === undefined && this.schema) + this.set(key, collectionFromPath(this.schema, rest, value)); + else + throw new Error(`Expected YAML collection at ${key}. Remaining path: ${rest}`); + } + } +} + +exports.Collection = Collection; +exports.collectionFromPath = collectionFromPath; +exports.isEmptyPath = isEmptyPath; diff --git a/bundled-addons/pithos/vendor/yaml/lib/nodes/Node.js b/bundled-addons/pithos/vendor/yaml/lib/nodes/Node.js new file mode 100644 index 00000000..d384e1cd --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/nodes/Node.js @@ -0,0 +1,40 @@ +'use strict'; + +var applyReviver = require('../doc/applyReviver.js'); +var identity = require('./identity.js'); +var toJS = require('./toJS.js'); + +class NodeBase { + constructor(type) { + Object.defineProperty(this, identity.NODE_TYPE, { value: type }); + } + /** Create a copy of this node. */ + clone() { + const copy = Object.create(Object.getPrototypeOf(this), Object.getOwnPropertyDescriptors(this)); + if (this.range) + copy.range = this.range.slice(); + return copy; + } + /** A plain JavaScript representation of this node. */ + toJS(doc, { mapAsMap, maxAliasCount, onAnchor, reviver } = {}) { + if (!identity.isDocument(doc)) + throw new TypeError('A document argument is required'); + const ctx = { + anchors: new Map(), + doc, + keep: true, + mapAsMap: mapAsMap === true, + mapKeyWarned: false, + maxAliasCount: typeof maxAliasCount === 'number' ? maxAliasCount : 100 + }; + const res = toJS.toJS(this, '', ctx); + if (typeof onAnchor === 'function') + for (const { count, res } of ctx.anchors.values()) + onAnchor(res, count); + return typeof reviver === 'function' + ? applyReviver.applyReviver(reviver, { '': res }, '', res) + : res; + } +} + +exports.NodeBase = NodeBase; diff --git a/bundled-addons/pithos/vendor/yaml/lib/nodes/Pair.js b/bundled-addons/pithos/vendor/yaml/lib/nodes/Pair.js new file mode 100644 index 00000000..ae4c7727 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/nodes/Pair.js @@ -0,0 +1,39 @@ +'use strict'; + +var createNode = require('../doc/createNode.js'); +var stringifyPair = require('../stringify/stringifyPair.js'); +var addPairToJSMap = require('./addPairToJSMap.js'); +var identity = require('./identity.js'); + +function createPair(key, value, ctx) { + const k = createNode.createNode(key, undefined, ctx); + const v = createNode.createNode(value, undefined, ctx); + return new Pair(k, v); +} +class Pair { + constructor(key, value = null) { + Object.defineProperty(this, identity.NODE_TYPE, { value: identity.PAIR }); + this.key = key; + this.value = value; + } + clone(schema) { + let { key, value } = this; + if (identity.isNode(key)) + key = key.clone(schema); + if (identity.isNode(value)) + value = value.clone(schema); + return new Pair(key, value); + } + toJSON(_, ctx) { + const pair = ctx?.mapAsMap ? new Map() : {}; + return addPairToJSMap.addPairToJSMap(ctx, pair, this); + } + toString(ctx, onComment, onChompKeep) { + return ctx?.doc + ? stringifyPair.stringifyPair(this, ctx, onComment, onChompKeep) + : JSON.stringify(this); + } +} + +exports.Pair = Pair; +exports.createPair = createPair; diff --git a/bundled-addons/pithos/vendor/yaml/lib/nodes/Scalar.js b/bundled-addons/pithos/vendor/yaml/lib/nodes/Scalar.js new file mode 100644 index 00000000..bd7d4d22 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/nodes/Scalar.js @@ -0,0 +1,27 @@ +'use strict'; + +var identity = require('./identity.js'); +var Node = require('./Node.js'); +var toJS = require('./toJS.js'); + +const isScalarValue = (value) => !value || (typeof value !== 'function' && typeof value !== 'object'); +class Scalar extends Node.NodeBase { + constructor(value) { + super(identity.SCALAR); + this.value = value; + } + toJSON(arg, ctx) { + return ctx?.keep ? this.value : toJS.toJS(this.value, arg, ctx); + } + toString() { + return String(this.value); + } +} +Scalar.BLOCK_FOLDED = 'BLOCK_FOLDED'; +Scalar.BLOCK_LITERAL = 'BLOCK_LITERAL'; +Scalar.PLAIN = 'PLAIN'; +Scalar.QUOTE_DOUBLE = 'QUOTE_DOUBLE'; +Scalar.QUOTE_SINGLE = 'QUOTE_SINGLE'; + +exports.Scalar = Scalar; +exports.isScalarValue = isScalarValue; diff --git a/bundled-addons/pithos/vendor/yaml/lib/nodes/YAMLMap.js b/bundled-addons/pithos/vendor/yaml/lib/nodes/YAMLMap.js new file mode 100644 index 00000000..210abbfc --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/nodes/YAMLMap.js @@ -0,0 +1,147 @@ +'use strict'; + +var stringifyCollection = require('../stringify/stringifyCollection.js'); +var addPairToJSMap = require('./addPairToJSMap.js'); +var Collection = require('./Collection.js'); +var identity = require('./identity.js'); +var Pair = require('./Pair.js'); +var Scalar = require('./Scalar.js'); + +function findPair(items, key) { + const k = identity.isScalar(key) ? key.value : key; + for (const it of items) { + if (identity.isPair(it)) { + if (it.key === key || it.key === k) + return it; + if (identity.isScalar(it.key) && it.key.value === k) + return it; + } + } + return undefined; +} +class YAMLMap extends Collection.Collection { + static get tagName() { + return 'tag:yaml.org,2002:map'; + } + constructor(schema) { + super(identity.MAP, schema); + this.items = []; + } + /** + * A generic collection parsing method that can be extended + * to other node classes that inherit from YAMLMap + */ + static from(schema, obj, ctx) { + const { keepUndefined, replacer } = ctx; + const map = new this(schema); + const add = (key, value) => { + if (typeof replacer === 'function') + value = replacer.call(obj, key, value); + else if (Array.isArray(replacer) && !replacer.includes(key)) + return; + if (value !== undefined || keepUndefined) + map.items.push(Pair.createPair(key, value, ctx)); + }; + if (obj instanceof Map) { + for (const [key, value] of obj) + add(key, value); + } + else if (obj && typeof obj === 'object') { + for (const key of Object.keys(obj)) + add(key, obj[key]); + } + if (typeof schema.sortMapEntries === 'function') { + map.items.sort(schema.sortMapEntries); + } + return map; + } + /** + * Adds a value to the collection. + * + * @param overwrite - If not set `true`, using a key that is already in the + * collection will throw. Otherwise, overwrites the previous value. + */ + add(pair, overwrite) { + let _pair; + if (identity.isPair(pair)) + _pair = pair; + else if (!pair || typeof pair !== 'object' || !('key' in pair)) { + // In TypeScript, this never happens. + _pair = new Pair.Pair(pair, pair?.value); + } + else + _pair = new Pair.Pair(pair.key, pair.value); + const prev = findPair(this.items, _pair.key); + const sortEntries = this.schema?.sortMapEntries; + if (prev) { + if (!overwrite) + throw new Error(`Key ${_pair.key} already set`); + // For scalars, keep the old node & its comments and anchors + if (identity.isScalar(prev.value) && Scalar.isScalarValue(_pair.value)) + prev.value.value = _pair.value; + else + prev.value = _pair.value; + } + else if (sortEntries) { + const i = this.items.findIndex(item => sortEntries(_pair, item) < 0); + if (i === -1) + this.items.push(_pair); + else + this.items.splice(i, 0, _pair); + } + else { + this.items.push(_pair); + } + } + delete(key) { + const it = findPair(this.items, key); + if (!it) + return false; + const del = this.items.splice(this.items.indexOf(it), 1); + return del.length > 0; + } + get(key, keepScalar) { + const it = findPair(this.items, key); + const node = it?.value; + return (!keepScalar && identity.isScalar(node) ? node.value : node) ?? undefined; + } + has(key) { + return !!findPair(this.items, key); + } + set(key, value) { + this.add(new Pair.Pair(key, value), true); + } + /** + * @param ctx - Conversion context, originally set in Document#toJS() + * @param {Class} Type - If set, forces the returned collection type + * @returns Instance of Type, Map, or Object + */ + toJSON(_, ctx, Type) { + const map = Type ? new Type() : ctx?.mapAsMap ? new Map() : {}; + if (ctx?.onCreate) + ctx.onCreate(map); + for (const item of this.items) + addPairToJSMap.addPairToJSMap(ctx, map, item); + return map; + } + toString(ctx, onComment, onChompKeep) { + if (!ctx) + return JSON.stringify(this); + for (const item of this.items) { + if (!identity.isPair(item)) + throw new Error(`Map items must all be pairs; found ${JSON.stringify(item)} instead`); + } + if (!ctx.allNullValues && this.hasAllNullValues(false)) + ctx = Object.assign({}, ctx, { allNullValues: true }); + return stringifyCollection.stringifyCollection(this, ctx, { + blockItemPrefix: '', + flowChars: { start: '{', end: '}' }, + itemIndent: ctx.indent || '', + onChompKeep, + onComment + }); + } +} + +exports.YAMLMap = YAMLMap; +exports.findPair = findPair; diff --git a/bundled-addons/pithos/vendor/yaml/lib/nodes/YAMLSeq.js b/bundled-addons/pithos/vendor/yaml/lib/nodes/YAMLSeq.js new file mode 100644 index 00000000..a2af086d --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/nodes/YAMLSeq.js @@ -0,0 +1,115 @@ +'use strict'; + +var createNode = require('../doc/createNode.js'); +var stringifyCollection = require('../stringify/stringifyCollection.js'); +var Collection = require('./Collection.js'); +var identity = require('./identity.js'); +var Scalar = require('./Scalar.js'); +var toJS = require('./toJS.js'); + +class YAMLSeq extends Collection.Collection { + static get tagName() { + return 'tag:yaml.org,2002:seq'; + } + constructor(schema) { + super(identity.SEQ, schema); + this.items = []; + } + add(value) { + this.items.push(value); + } + /** + * Removes a value from the collection. + * + * `key` must contain a representation of an integer for this to succeed. + * It may be wrapped in a `Scalar`. + * + * @returns `true` if the item was found and removed. + */ + delete(key) { + const idx = asItemIndex(key); + if (typeof idx !== 'number') + return false; + const del = this.items.splice(idx, 1); + return del.length > 0; + } + get(key, keepScalar) { + const idx = asItemIndex(key); + if (typeof idx !== 'number') + return undefined; + const it = this.items[idx]; + return !keepScalar && identity.isScalar(it) ? it.value : it; + } + /** + * Checks if the collection includes a value with the key `key`. + * + * `key` must contain a representation of an integer for this to succeed. + * It may be wrapped in a `Scalar`. + */ + has(key) { + const idx = asItemIndex(key); + return typeof idx === 'number' && idx < this.items.length; + } + /** + * Sets a value in this collection. For `!!set`, `value` needs to be a + * boolean to add/remove the item from the set. + * + * If `key` does not contain a representation of an integer, this will throw. + * It may be wrapped in a `Scalar`. + */ + set(key, value) { + const idx = asItemIndex(key); + if (typeof idx !== 'number') + throw new Error(`Expected a valid index, not ${key}.`); + const prev = this.items[idx]; + if (identity.isScalar(prev) && Scalar.isScalarValue(value)) + prev.value = value; + else + this.items[idx] = value; + } + toJSON(_, ctx) { + const seq = []; + if (ctx?.onCreate) + ctx.onCreate(seq); + let i = 0; + for (const item of this.items) + seq.push(toJS.toJS(item, String(i++), ctx)); + return seq; + } + toString(ctx, onComment, onChompKeep) { + if (!ctx) + return JSON.stringify(this); + return stringifyCollection.stringifyCollection(this, ctx, { + blockItemPrefix: '- ', + flowChars: { start: '[', end: ']' }, + itemIndent: (ctx.indent || '') + ' ', + onChompKeep, + onComment + }); + } + static from(schema, obj, ctx) { + const { replacer } = ctx; + const seq = new this(schema); + if (obj && Symbol.iterator in Object(obj)) { + let i = 0; + for (let it of obj) { + if (typeof replacer === 'function') { + const key = obj instanceof Set ? it : String(i++); + it = replacer.call(obj, key, it); + } + seq.items.push(createNode.createNode(it, undefined, ctx)); + } + } + return seq; + } +} +function asItemIndex(key) { + let idx = identity.isScalar(key) ? key.value : key; + if (idx && typeof idx === 'string') + idx = Number(idx); + return typeof idx === 'number' && Number.isInteger(idx) && idx >= 0 + ? idx + : null; +} + +exports.YAMLSeq = YAMLSeq; diff --git a/bundled-addons/pithos/vendor/yaml/lib/nodes/addPairToJSMap.js b/bundled-addons/pithos/vendor/yaml/lib/nodes/addPairToJSMap.js new file mode 100644 index 00000000..a8faa290 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/nodes/addPairToJSMap.js @@ -0,0 +1,65 @@ +'use strict'; + +var log = require('../log.js'); +var merge = require('../schema/yaml-1.1/merge.js'); +var stringify = require('../stringify/stringify.js'); +var identity = require('./identity.js'); +var toJS = require('./toJS.js'); + +function addPairToJSMap(ctx, map, { key, value }) { + if (identity.isNode(key) && key.addToJSMap) + key.addToJSMap(ctx, map, value); + // TODO: Should drop this special case for bare << handling + else if (merge.isMergeKey(ctx, key)) + merge.addMergeToJSMap(ctx, map, value); + else { + const jsKey = toJS.toJS(key, '', ctx); + if (map instanceof Map) { + map.set(jsKey, toJS.toJS(value, jsKey, ctx)); + } + else if (map instanceof Set) { + map.add(jsKey); + } + else { + const stringKey = stringifyKey(key, jsKey, ctx); + const jsValue = toJS.toJS(value, stringKey, ctx); + if (stringKey in map) + Object.defineProperty(map, stringKey, { + value: jsValue, + writable: true, + enumerable: true, + configurable: true + }); + else + map[stringKey] = jsValue; + } + } + return map; +} +function stringifyKey(key, jsKey, ctx) { + if (jsKey === null) + return ''; + // eslint-disable-next-line @typescript-eslint/no-base-to-string + if (typeof jsKey !== 'object') + return String(jsKey); + if (identity.isNode(key) && ctx?.doc) { + const strCtx = stringify.createStringifyContext(ctx.doc, {}); + strCtx.anchors = new Set(); + for (const node of ctx.anchors.keys()) + strCtx.anchors.add(node.anchor); + strCtx.inFlow = true; + strCtx.inStringifyKey = true; + const strKey = key.toString(strCtx); + if (!ctx.mapKeyWarned) { + let jsonStr = JSON.stringify(strKey); + if (jsonStr.length > 40) + jsonStr = jsonStr.substring(0, 36) + '..."'; + log.warn(ctx.doc.options.logLevel, `Keys with collection values will be stringified due to JS Object restrictions: ${jsonStr}. Set mapAsMap: true to use object keys.`); + ctx.mapKeyWarned = true; + } + return strKey; + } + return JSON.stringify(jsKey); +} + +exports.addPairToJSMap = addPairToJSMap; diff --git a/bundled-addons/pithos/vendor/yaml/lib/nodes/identity.js b/bundled-addons/pithos/vendor/yaml/lib/nodes/identity.js new file mode 100644 index 00000000..5794aa3f --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/nodes/identity.js @@ -0,0 +1,53 @@ +'use strict'; + +const ALIAS = Symbol.for('yaml.alias'); +const DOC = Symbol.for('yaml.document'); +const MAP = Symbol.for('yaml.map'); +const PAIR = Symbol.for('yaml.pair'); +const SCALAR = Symbol.for('yaml.scalar'); +const SEQ = Symbol.for('yaml.seq'); +const NODE_TYPE = Symbol.for('yaml.node.type'); +const isAlias = (node) => !!node && typeof node === 'object' && node[NODE_TYPE] === ALIAS; +const isDocument = (node) => !!node && typeof node === 'object' && node[NODE_TYPE] === DOC; +const isMap = (node) => !!node && typeof node === 'object' && node[NODE_TYPE] === MAP; +const isPair = (node) => !!node && typeof node === 'object' && node[NODE_TYPE] === PAIR; +const isScalar = (node) => !!node && typeof node === 'object' && node[NODE_TYPE] === SCALAR; +const isSeq = (node) => !!node && typeof node === 'object' && node[NODE_TYPE] === SEQ; +function isCollection(node) { + if (node && typeof node === 'object') + switch (node[NODE_TYPE]) { + case MAP: + case SEQ: + return true; + } + return false; +} +function isNode(node) { + if (node && typeof node === 'object') + switch (node[NODE_TYPE]) { + case ALIAS: + case MAP: + case SCALAR: + case SEQ: + return true; + } + return false; +} +const hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor; + +exports.ALIAS = ALIAS; +exports.DOC = DOC; +exports.MAP = MAP; +exports.NODE_TYPE = NODE_TYPE; +exports.PAIR = PAIR; +exports.SCALAR = SCALAR; +exports.SEQ = SEQ; +exports.hasAnchor = hasAnchor; +exports.isAlias = isAlias; +exports.isCollection = isCollection; +exports.isDocument = isDocument; +exports.isMap = isMap; +exports.isNode = isNode; +exports.isPair = isPair; +exports.isScalar = isScalar; +exports.isSeq = isSeq; diff --git a/bundled-addons/pithos/vendor/yaml/lib/nodes/toJS.js b/bundled-addons/pithos/vendor/yaml/lib/nodes/toJS.js new file mode 100644 index 00000000..a012823b --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/nodes/toJS.js @@ -0,0 +1,39 @@ +'use strict'; + +var identity = require('./identity.js'); + +/** + * Recursively convert any node or its contents to native JavaScript + * + * @param value - The input value + * @param arg - If `value` defines a `toJSON()` method, use this + * as its first argument + * @param ctx - Conversion context, originally set in Document#toJS(). If + * `{ keep: true }` is not set, output should be suitable for JSON + * stringification. + */ +function toJS(value, arg, ctx) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + if (Array.isArray(value)) + return value.map((v, i) => toJS(v, String(i), ctx)); + if (value && typeof value.toJSON === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + if (!ctx || !identity.hasAnchor(value)) + return value.toJSON(arg, ctx); + const data = { aliasCount: 0, count: 1, res: undefined }; + ctx.anchors.set(value, data); + ctx.onCreate = res => { + data.res = res; + delete ctx.onCreate; + }; + const res = value.toJSON(arg, ctx); + if (ctx.onCreate) + ctx.onCreate(res); + return res; + } + if (typeof value === 'bigint' && !ctx?.keep) + return Number(value); + return value; +} + +exports.toJS = toJS; diff --git a/bundled-addons/pithos/vendor/yaml/lib/parse/cst-scalar.js b/bundled-addons/pithos/vendor/yaml/lib/parse/cst-scalar.js new file mode 100644 index 00000000..81b8463f --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/parse/cst-scalar.js @@ -0,0 +1,218 @@ +'use strict'; + +var resolveBlockScalar = require('../compose/resolve-block-scalar.js'); +var resolveFlowScalar = require('../compose/resolve-flow-scalar.js'); +var errors = require('../errors.js'); +var stringifyString = require('../stringify/stringifyString.js'); + +function resolveAsScalar(token, strict = true, onError) { + if (token) { + const _onError = (pos, code, message) => { + const offset = typeof pos === 'number' ? pos : Array.isArray(pos) ? pos[0] : pos.offset; + if (onError) + onError(offset, code, message); + else + throw new errors.YAMLParseError([offset, offset + 1], code, message); + }; + switch (token.type) { + case 'scalar': + case 'single-quoted-scalar': + case 'double-quoted-scalar': + return resolveFlowScalar.resolveFlowScalar(token, strict, _onError); + case 'block-scalar': + return resolveBlockScalar.resolveBlockScalar({ options: { strict } }, token, _onError); + } + } + return null; +} +/** + * Create a new scalar token with `value` + * + * Values that represent an actual string but may be parsed as a different type should use a `type` other than `'PLAIN'`, + * as this function does not support any schema operations and won't check for such conflicts. + * + * @param value The string representation of the value, which will have its content properly indented. + * @param context.end Comments and whitespace after the end of the value, or after the block scalar header. If undefined, a newline will be added. + * @param context.implicitKey Being within an implicit key may affect the resolved type of the token's value. + * @param context.indent The indent level of the token. + * @param context.inFlow Is this scalar within a flow collection? This may affect the resolved type of the token's value. + * @param context.offset The offset position of the token. + * @param context.type The preferred type of the scalar token. If undefined, the previous type of the `token` will be used, defaulting to `'PLAIN'`. + */ +function createScalarToken(value, context) { + const { implicitKey = false, indent, inFlow = false, offset = -1, type = 'PLAIN' } = context; + const source = stringifyString.stringifyString({ type, value }, { + implicitKey, + indent: indent > 0 ? ' '.repeat(indent) : '', + inFlow, + options: { blockQuote: true, lineWidth: -1 } + }); + const end = context.end ?? [ + { type: 'newline', offset: -1, indent, source: '\n' } + ]; + switch (source[0]) { + case '|': + case '>': { + const he = source.indexOf('\n'); + const head = source.substring(0, he); + const body = source.substring(he + 1) + '\n'; + const props = [ + { type: 'block-scalar-header', offset, indent, source: head } + ]; + if (!addEndtoBlockProps(props, end)) + props.push({ type: 'newline', offset: -1, indent, source: '\n' }); + return { type: 'block-scalar', offset, indent, props, source: body }; + } + case '"': + return { type: 'double-quoted-scalar', offset, indent, source, end }; + case "'": + return { type: 'single-quoted-scalar', offset, indent, source, end }; + default: + return { type: 'scalar', offset, indent, source, end }; + } +} +/** + * Set the value of `token` to the given string `value`, overwriting any previous contents and type that it may have. + * + * Best efforts are made to retain any comments previously associated with the `token`, + * though all contents within a collection's `items` will be overwritten. + * + * Values that represent an actual string but may be parsed as a different type should use a `type` other than `'PLAIN'`, + * as this function does not support any schema operations and won't check for such conflicts. + * + * @param token Any token. If it does not include an `indent` value, the value will be stringified as if it were an implicit key. + * @param value The string representation of the value, which will have its content properly indented. + * @param context.afterKey In most cases, values after a key should have an additional level of indentation. + * @param context.implicitKey Being within an implicit key may affect the resolved type of the token's value. + * @param context.inFlow Being within a flow collection may affect the resolved type of the token's value. + * @param context.type The preferred type of the scalar token. If undefined, the previous type of the `token` will be used, defaulting to `'PLAIN'`. + */ +function setScalarValue(token, value, context = {}) { + let { afterKey = false, implicitKey = false, inFlow = false, type } = context; + let indent = 'indent' in token ? token.indent : null; + if (afterKey && typeof indent === 'number') + indent += 2; + if (!type) + switch (token.type) { + case 'single-quoted-scalar': + type = 'QUOTE_SINGLE'; + break; + case 'double-quoted-scalar': + type = 'QUOTE_DOUBLE'; + break; + case 'block-scalar': { + const header = token.props[0]; + if (header.type !== 'block-scalar-header') + throw new Error('Invalid block scalar header'); + type = header.source[0] === '>' ? 'BLOCK_FOLDED' : 'BLOCK_LITERAL'; + break; + } + default: + type = 'PLAIN'; + } + const source = stringifyString.stringifyString({ type, value }, { + implicitKey: implicitKey || indent === null, + indent: indent !== null && indent > 0 ? ' '.repeat(indent) : '', + inFlow, + options: { blockQuote: true, lineWidth: -1 } + }); + switch (source[0]) { + case '|': + case '>': + setBlockScalarValue(token, source); + break; + case '"': + setFlowScalarValue(token, source, 'double-quoted-scalar'); + break; + case "'": + setFlowScalarValue(token, source, 'single-quoted-scalar'); + break; + default: + setFlowScalarValue(token, source, 'scalar'); + } +} +function setBlockScalarValue(token, source) { + const he = source.indexOf('\n'); + const head = source.substring(0, he); + const body = source.substring(he + 1) + '\n'; + if (token.type === 'block-scalar') { + const header = token.props[0]; + if (header.type !== 'block-scalar-header') + throw new Error('Invalid block scalar header'); + header.source = head; + token.source = body; + } + else { + const { offset } = token; + const indent = 'indent' in token ? token.indent : -1; + const props = [ + { type: 'block-scalar-header', offset, indent, source: head } + ]; + if (!addEndtoBlockProps(props, 'end' in token ? token.end : undefined)) + props.push({ type: 'newline', offset: -1, indent, source: '\n' }); + for (const key of Object.keys(token)) + if (key !== 'type' && key !== 'offset') + delete token[key]; + Object.assign(token, { type: 'block-scalar', indent, props, source: body }); + } +} +/** @returns `true` if last token is a newline */ +function addEndtoBlockProps(props, end) { + if (end) + for (const st of end) + switch (st.type) { + case 'space': + case 'comment': + props.push(st); + break; + case 'newline': + props.push(st); + return true; + } + return false; +} +function setFlowScalarValue(token, source, type) { + switch (token.type) { + case 'scalar': + case 'double-quoted-scalar': + case 'single-quoted-scalar': + token.type = type; + token.source = source; + break; + case 'block-scalar': { + const end = token.props.slice(1); + let oa = source.length; + if (token.props[0].type === 'block-scalar-header') + oa -= token.props[0].source.length; + for (const tok of end) + tok.offset += oa; + delete token.props; + Object.assign(token, { type, source, end }); + break; + } + case 'block-map': + case 'block-seq': { + const offset = token.offset + source.length; + const nl = { type: 'newline', offset, indent: token.indent, source: '\n' }; + delete token.items; + Object.assign(token, { type, source, end: [nl] }); + break; + } + default: { + const indent = 'indent' in token ? token.indent : -1; + const end = 'end' in token && Array.isArray(token.end) + ? token.end.filter(st => st.type === 'space' || + st.type === 'comment' || + st.type === 'newline') + : []; + for (const key of Object.keys(token)) + if (key !== 'type' && key !== 'offset') + delete token[key]; + Object.assign(token, { type, indent, source, end }); + } + } +} + +exports.createScalarToken = createScalarToken; +exports.resolveAsScalar = resolveAsScalar; +exports.setScalarValue = setScalarValue; diff --git a/bundled-addons/pithos/vendor/yaml/lib/parse/cst-stringify.js b/bundled-addons/pithos/vendor/yaml/lib/parse/cst-stringify.js new file mode 100644 index 00000000..78e8c372 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/parse/cst-stringify.js @@ -0,0 +1,63 @@ +'use strict'; + +/** + * Stringify a CST document, token, or collection item + * + * Fair warning: This applies no validation whatsoever, and + * simply concatenates the sources in their logical order. + */ +const stringify = (cst) => 'type' in cst ? stringifyToken(cst) : stringifyItem(cst); +function stringifyToken(token) { + switch (token.type) { + case 'block-scalar': { + let res = ''; + for (const tok of token.props) + res += stringifyToken(tok); + return res + token.source; + } + case 'block-map': + case 'block-seq': { + let res = ''; + for (const item of token.items) + res += stringifyItem(item); + return res; + } + case 'flow-collection': { + let res = token.start.source; + for (const item of token.items) + res += stringifyItem(item); + for (const st of token.end) + res += st.source; + return res; + } + case 'document': { + let res = stringifyItem(token); + if (token.end) + for (const st of token.end) + res += st.source; + return res; + } + default: { + let res = token.source; + if ('end' in token && token.end) + for (const st of token.end) + res += st.source; + return res; + } + } +} +function stringifyItem({ start, key, sep, value }) { + let res = ''; + for (const st of start) + res += st.source; + if (key) + res += stringifyToken(key); + if (sep) + for (const st of sep) + res += st.source; + if (value) + res += stringifyToken(value); + return res; +} + +exports.stringify = stringify; diff --git a/bundled-addons/pithos/vendor/yaml/lib/parse/cst-visit.js b/bundled-addons/pithos/vendor/yaml/lib/parse/cst-visit.js new file mode 100644 index 00000000..9ceee936 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/parse/cst-visit.js @@ -0,0 +1,99 @@ +'use strict'; + +const BREAK = Symbol('break visit'); +const SKIP = Symbol('skip children'); +const REMOVE = Symbol('remove item'); +/** + * Apply a visitor to a CST document or item. + * + * Walks through the tree (depth-first) starting from the root, calling a + * `visitor` function with two arguments when entering each item: + * - `item`: The current item, which included the following members: + * - `start: SourceToken[]` – Source tokens before the key or value, + * possibly including its anchor or tag. + * - `key?: Token | null` – Set for pair values. May then be `null`, if + * the key before the `:` separator is empty. + * - `sep?: SourceToken[]` – Source tokens between the key and the value, + * which should include the `:` map value indicator if `value` is set. + * - `value?: Token` – The value of a sequence item, or of a map pair. + * - `path`: The steps from the root to the current node, as an array of + * `['key' | 'value', number]` tuples. + * + * The return value of the visitor may be used to control the traversal: + * - `undefined` (default): Do nothing and continue + * - `visit.SKIP`: Do not visit the children of this token, continue with + * next sibling + * - `visit.BREAK`: Terminate traversal completely + * - `visit.REMOVE`: Remove the current item, then continue with the next one + * - `number`: Set the index of the next step. This is useful especially if + * the index of the current token has changed. + * - `function`: Define the next visitor for this item. After the original + * visitor is called on item entry, next visitors are called after handling + * a non-empty `key` and when exiting the item. + */ +function visit(cst, visitor) { + if ('type' in cst && cst.type === 'document') + cst = { start: cst.start, value: cst.value }; + _visit(Object.freeze([]), cst, visitor); +} +// Without the `as symbol` casts, TS declares these in the `visit` +// namespace using `var`, but then complains about that because +// `unique symbol` must be `const`. +/** Terminate visit traversal completely */ +visit.BREAK = BREAK; +/** Do not visit the children of the current item */ +visit.SKIP = SKIP; +/** Remove the current item */ +visit.REMOVE = REMOVE; +/** Find the item at `path` from `cst` as the root */ +visit.itemAtPath = (cst, path) => { + let item = cst; + for (const [field, index] of path) { + const tok = item?.[field]; + if (tok && 'items' in tok) { + item = tok.items[index]; + } + else + return undefined; + } + return item; +}; +/** + * Get the immediate parent collection of the item at `path` from `cst` as the root. + * + * Throws an error if the collection is not found, which should never happen if the item itself exists. + */ +visit.parentCollection = (cst, path) => { + const parent = visit.itemAtPath(cst, path.slice(0, -1)); + const field = path[path.length - 1][0]; + const coll = parent?.[field]; + if (coll && 'items' in coll) + return coll; + throw new Error('Parent collection not found'); +}; +function _visit(path, item, visitor) { + let ctrl = visitor(item, path); + if (typeof ctrl === 'symbol') + return ctrl; + for (const field of ['key', 'value']) { + const token = item[field]; + if (token && 'items' in token) { + for (let i = 0; i < token.items.length; ++i) { + const ci = _visit(Object.freeze(path.concat([[field, i]])), token.items[i], visitor); + if (typeof ci === 'number') + i = ci - 1; + else if (ci === BREAK) + return BREAK; + else if (ci === REMOVE) { + token.items.splice(i, 1); + i -= 1; + } + } + if (typeof ctrl === 'function' && field === 'key') + ctrl = ctrl(item, path); + } + } + return typeof ctrl === 'function' ? ctrl(item, path) : ctrl; +} + +exports.visit = visit; diff --git a/bundled-addons/pithos/vendor/yaml/lib/parse/cst.js b/bundled-addons/pithos/vendor/yaml/lib/parse/cst.js new file mode 100644 index 00000000..613c229b --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/parse/cst.js @@ -0,0 +1,112 @@ +'use strict'; + +var cstScalar = require('./cst-scalar.js'); +var cstStringify = require('./cst-stringify.js'); +var cstVisit = require('./cst-visit.js'); + +/** The byte order mark */ +const BOM = '\u{FEFF}'; +/** Start of doc-mode */ +const DOCUMENT = '\x02'; // C0: Start of Text +/** Unexpected end of flow-mode */ +const FLOW_END = '\x18'; // C0: Cancel +/** Next token is a scalar value */ +const SCALAR = '\x1f'; // C0: Unit Separator +/** @returns `true` if `token` is a flow or block collection */ +const isCollection = (token) => !!token && 'items' in token; +/** @returns `true` if `token` is a flow or block scalar; not an alias */ +const isScalar = (token) => !!token && + (token.type === 'scalar' || + token.type === 'single-quoted-scalar' || + token.type === 'double-quoted-scalar' || + token.type === 'block-scalar'); +/* istanbul ignore next */ +/** Get a printable representation of a lexer token */ +function prettyToken(token) { + switch (token) { + case BOM: + return ''; + case DOCUMENT: + return ''; + case FLOW_END: + return ''; + case SCALAR: + return ''; + default: + return JSON.stringify(token); + } +} +/** Identify the type of a lexer token. May return `null` for unknown tokens. */ +function tokenType(source) { + switch (source) { + case BOM: + return 'byte-order-mark'; + case DOCUMENT: + return 'doc-mode'; + case FLOW_END: + return 'flow-error-end'; + case SCALAR: + return 'scalar'; + case '---': + return 'doc-start'; + case '...': + return 'doc-end'; + case '': + case '\n': + case '\r\n': + return 'newline'; + case '-': + return 'seq-item-ind'; + case '?': + return 'explicit-key-ind'; + case ':': + return 'map-value-ind'; + case '{': + return 'flow-map-start'; + case '}': + return 'flow-map-end'; + case '[': + return 'flow-seq-start'; + case ']': + return 'flow-seq-end'; + case ',': + return 'comma'; + } + switch (source[0]) { + case ' ': + case '\t': + return 'space'; + case '#': + return 'comment'; + case '%': + return 'directive-line'; + case '*': + return 'alias'; + case '&': + return 'anchor'; + case '!': + return 'tag'; + case "'": + return 'single-quoted-scalar'; + case '"': + return 'double-quoted-scalar'; + case '|': + case '>': + return 'block-scalar-header'; + } + return null; +} + +exports.createScalarToken = cstScalar.createScalarToken; +exports.resolveAsScalar = cstScalar.resolveAsScalar; +exports.setScalarValue = cstScalar.setScalarValue; +exports.stringify = cstStringify.stringify; +exports.visit = cstVisit.visit; +exports.BOM = BOM; +exports.DOCUMENT = DOCUMENT; +exports.FLOW_END = FLOW_END; +exports.SCALAR = SCALAR; +exports.isCollection = isCollection; +exports.isScalar = isScalar; +exports.prettyToken = prettyToken; +exports.tokenType = tokenType; diff --git a/bundled-addons/pithos/vendor/yaml/lib/parse/lexer.js b/bundled-addons/pithos/vendor/yaml/lib/parse/lexer.js new file mode 100644 index 00000000..01d8643b --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/parse/lexer.js @@ -0,0 +1,723 @@ +'use strict'; + +var cst = require('./cst.js'); + +/* +START -> stream + +stream + directive -> line-end -> stream + indent + line-end -> stream + [else] -> line-start + +line-end + comment -> line-end + newline -> . + input-end -> END + +line-start + doc-start -> doc + doc-end -> stream + [else] -> indent -> block-start + +block-start + seq-item-start -> block-start + explicit-key-start -> block-start + map-value-start -> block-start + [else] -> doc + +doc + line-end -> line-start + spaces -> doc + anchor -> doc + tag -> doc + flow-start -> flow -> doc + flow-end -> error -> doc + seq-item-start -> error -> doc + explicit-key-start -> error -> doc + map-value-start -> doc + alias -> doc + quote-start -> quoted-scalar -> doc + block-scalar-header -> line-end -> block-scalar(min) -> line-start + [else] -> plain-scalar(false, min) -> doc + +flow + line-end -> flow + spaces -> flow + anchor -> flow + tag -> flow + flow-start -> flow -> flow + flow-end -> . + seq-item-start -> error -> flow + explicit-key-start -> flow + map-value-start -> flow + alias -> flow + quote-start -> quoted-scalar -> flow + comma -> flow + [else] -> plain-scalar(true, 0) -> flow + +quoted-scalar + quote-end -> . + [else] -> quoted-scalar + +block-scalar(min) + newline + peek(indent < min) -> . + [else] -> block-scalar(min) + +plain-scalar(is-flow, min) + scalar-end(is-flow) -> . + peek(newline + (indent < min)) -> . + [else] -> plain-scalar(min) +*/ +function isEmpty(ch) { + switch (ch) { + case undefined: + case ' ': + case '\n': + case '\r': + case '\t': + return true; + default: + return false; + } +} +const hexDigits = new Set('0123456789ABCDEFabcdef'); +const tagChars = new Set("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-#;/?:@&=+$_.!~*'()"); +const flowIndicatorChars = new Set(',[]{}'); +const invalidAnchorChars = new Set(' ,[]{}\n\r\t'); +const isNotAnchorChar = (ch) => !ch || invalidAnchorChars.has(ch); +/** + * Splits an input string into lexical tokens, i.e. smaller strings that are + * easily identifiable by `tokens.tokenType()`. + * + * Lexing starts always in a "stream" context. Incomplete input may be buffered + * until a complete token can be emitted. + * + * In addition to slices of the original input, the following control characters + * may also be emitted: + * + * - `\x02` (Start of Text): A document starts with the next token + * - `\x18` (Cancel): Unexpected end of flow-mode (indicates an error) + * - `\x1f` (Unit Separator): Next token is a scalar value + * - `\u{FEFF}` (Byte order mark): Emitted separately outside documents + */ +class Lexer { + constructor() { + /** + * Flag indicating whether the end of the current buffer marks the end of + * all input + */ + this.atEnd = false; + /** + * Explicit indent set in block scalar header, as an offset from the current + * minimum indent, so e.g. set to 1 from a header `|2+`. Set to -1 if not + * explicitly set. + */ + this.blockScalarIndent = -1; + /** + * Block scalars that include a + (keep) chomping indicator in their header + * include trailing empty lines, which are otherwise excluded from the + * scalar's contents. + */ + this.blockScalarKeep = false; + /** Current input */ + this.buffer = ''; + /** + * Flag noting whether the map value indicator : can immediately follow this + * node within a flow context. + */ + this.flowKey = false; + /** Count of surrounding flow collection levels. */ + this.flowLevel = 0; + /** + * Minimum level of indentation required for next lines to be parsed as a + * part of the current scalar value. + */ + this.indentNext = 0; + /** Indentation level of the current line. */ + this.indentValue = 0; + /** Position of the next \n character. */ + this.lineEndPos = null; + /** Stores the state of the lexer if reaching the end of incpomplete input */ + this.next = null; + /** A pointer to `buffer`; the current position of the lexer. */ + this.pos = 0; + } + /** + * Generate YAML tokens from the `source` string. If `incomplete`, + * a part of the last line may be left as a buffer for the next call. + * + * @returns A generator of lexical tokens + */ + *lex(source, incomplete = false) { + if (source) { + if (typeof source !== 'string') + throw TypeError('source is not a string'); + this.buffer = this.buffer ? this.buffer + source : source; + this.lineEndPos = null; + } + this.atEnd = !incomplete; + let next = this.next ?? 'stream'; + while (next && (incomplete || this.hasChars(1))) + next = yield* this.parseNext(next); + } + atLineEnd() { + let i = this.pos; + let ch = this.buffer[i]; + while (ch === ' ' || ch === '\t') + ch = this.buffer[++i]; + if (!ch || ch === '#' || ch === '\n') + return true; + if (ch === '\r') + return this.buffer[i + 1] === '\n'; + return false; + } + charAt(n) { + return this.buffer[this.pos + n]; + } + continueScalar(offset) { + let ch = this.buffer[offset]; + if (this.indentNext > 0) { + let indent = 0; + while (ch === ' ') + ch = this.buffer[++indent + offset]; + if (ch === '\r') { + const next = this.buffer[indent + offset + 1]; + if (next === '\n' || (!next && !this.atEnd)) + return offset + indent + 1; + } + return ch === '\n' || indent >= this.indentNext || (!ch && !this.atEnd) + ? offset + indent + : -1; + } + if (ch === '-' || ch === '.') { + const dt = this.buffer.substr(offset, 3); + if ((dt === '---' || dt === '...') && isEmpty(this.buffer[offset + 3])) + return -1; + } + return offset; + } + getLine() { + let end = this.lineEndPos; + if (typeof end !== 'number' || (end !== -1 && end < this.pos)) { + end = this.buffer.indexOf('\n', this.pos); + this.lineEndPos = end; + } + if (end === -1) + return this.atEnd ? this.buffer.substring(this.pos) : null; + if (this.buffer[end - 1] === '\r') + end -= 1; + return this.buffer.substring(this.pos, end); + } + hasChars(n) { + return this.pos + n <= this.buffer.length; + } + setNext(state) { + this.buffer = this.buffer.substring(this.pos); + this.pos = 0; + this.lineEndPos = null; + this.next = state; + return null; + } + peek(n) { + return this.buffer.substr(this.pos, n); + } + *parseNext(next) { + switch (next) { + case 'stream': + return yield* this.parseStream(); + case 'line-start': + return yield* this.parseLineStart(); + case 'block-start': + return yield* this.parseBlockStart(); + case 'doc': + return yield* this.parseDocument(); + case 'flow': + return yield* this.parseFlowCollection(); + case 'quoted-scalar': + return yield* this.parseQuotedScalar(); + case 'block-scalar': + return yield* this.parseBlockScalar(); + case 'plain-scalar': + return yield* this.parsePlainScalar(); + } + } + *parseStream() { + let line = this.getLine(); + if (line === null) + return this.setNext('stream'); + if (line[0] === cst.BOM) { + yield* this.pushCount(1); + line = line.substring(1); + } + if (line[0] === '%') { + let dirEnd = line.length; + let cs = line.indexOf('#'); + while (cs !== -1) { + const ch = line[cs - 1]; + if (ch === ' ' || ch === '\t') { + dirEnd = cs - 1; + break; + } + else { + cs = line.indexOf('#', cs + 1); + } + } + while (true) { + const ch = line[dirEnd - 1]; + if (ch === ' ' || ch === '\t') + dirEnd -= 1; + else + break; + } + const n = (yield* this.pushCount(dirEnd)) + (yield* this.pushSpaces(true)); + yield* this.pushCount(line.length - n); // possible comment + this.pushNewline(); + return 'stream'; + } + if (this.atLineEnd()) { + const sp = yield* this.pushSpaces(true); + yield* this.pushCount(line.length - sp); + yield* this.pushNewline(); + return 'stream'; + } + yield cst.DOCUMENT; + return yield* this.parseLineStart(); + } + *parseLineStart() { + const ch = this.charAt(0); + if (!ch && !this.atEnd) + return this.setNext('line-start'); + if (ch === '-' || ch === '.') { + if (!this.atEnd && !this.hasChars(4)) + return this.setNext('line-start'); + const s = this.peek(3); + if ((s === '---' || s === '...') && isEmpty(this.charAt(3))) { + yield* this.pushCount(3); + this.indentValue = 0; + this.indentNext = 0; + return s === '---' ? 'doc' : 'stream'; + } + } + this.indentValue = yield* this.pushSpaces(false); + if (this.indentNext > this.indentValue && !isEmpty(this.charAt(1))) + this.indentNext = this.indentValue; + return yield* this.parseBlockStart(); + } + *parseBlockStart() { + const [ch0, ch1] = this.peek(2); + if (!ch1 && !this.atEnd) + return this.setNext('block-start'); + if ((ch0 === '-' || ch0 === '?' || ch0 === ':') && isEmpty(ch1)) { + const n = (yield* this.pushCount(1)) + (yield* this.pushSpaces(true)); + this.indentNext = this.indentValue + 1; + this.indentValue += n; + return 'block-start'; + } + return 'doc'; + } + *parseDocument() { + yield* this.pushSpaces(true); + const line = this.getLine(); + if (line === null) + return this.setNext('doc'); + let n = yield* this.pushIndicators(); + switch (line[n]) { + case '#': + yield* this.pushCount(line.length - n); + // fallthrough + case undefined: + yield* this.pushNewline(); + return yield* this.parseLineStart(); + case '{': + case '[': + yield* this.pushCount(1); + this.flowKey = false; + this.flowLevel = 1; + return 'flow'; + case '}': + case ']': + // this is an error + yield* this.pushCount(1); + return 'doc'; + case '*': + yield* this.pushUntil(isNotAnchorChar); + return 'doc'; + case '"': + case "'": + return yield* this.parseQuotedScalar(); + case '|': + case '>': + n += yield* this.parseBlockScalarHeader(); + n += yield* this.pushSpaces(true); + yield* this.pushCount(line.length - n); + yield* this.pushNewline(); + return yield* this.parseBlockScalar(); + default: + return yield* this.parsePlainScalar(); + } + } + *parseFlowCollection() { + let nl, sp; + let indent = -1; + do { + nl = yield* this.pushNewline(); + if (nl > 0) { + sp = yield* this.pushSpaces(false); + this.indentValue = indent = sp; + } + else { + sp = 0; + } + sp += yield* this.pushSpaces(true); + } while (nl + sp > 0); + const line = this.getLine(); + if (line === null) + return this.setNext('flow'); + if ((indent !== -1 && indent < this.indentNext && line[0] !== '#') || + (indent === 0 && + (line.startsWith('---') || line.startsWith('...')) && + isEmpty(line[3]))) { + // Allowing for the terminal ] or } at the same (rather than greater) + // indent level as the initial [ or { is technically invalid, but + // failing here would be surprising to users. + const atFlowEndMarker = indent === this.indentNext - 1 && + this.flowLevel === 1 && + (line[0] === ']' || line[0] === '}'); + if (!atFlowEndMarker) { + // this is an error + this.flowLevel = 0; + yield cst.FLOW_END; + return yield* this.parseLineStart(); + } + } + let n = 0; + while (line[n] === ',') { + n += yield* this.pushCount(1); + n += yield* this.pushSpaces(true); + this.flowKey = false; + } + n += yield* this.pushIndicators(); + switch (line[n]) { + case undefined: + return 'flow'; + case '#': + yield* this.pushCount(line.length - n); + return 'flow'; + case '{': + case '[': + yield* this.pushCount(1); + this.flowKey = false; + this.flowLevel += 1; + return 'flow'; + case '}': + case ']': + yield* this.pushCount(1); + this.flowKey = true; + this.flowLevel -= 1; + return this.flowLevel ? 'flow' : 'doc'; + case '*': + yield* this.pushUntil(isNotAnchorChar); + return 'flow'; + case '"': + case "'": + this.flowKey = true; + return yield* this.parseQuotedScalar(); + case ':': { + const next = this.charAt(1); + if (this.flowKey || isEmpty(next) || next === ',') { + this.flowKey = false; + yield* this.pushCount(1); + yield* this.pushSpaces(true); + return 'flow'; + } + } + // fallthrough + default: + this.flowKey = false; + return yield* this.parsePlainScalar(); + } + } + *parseQuotedScalar() { + const quote = this.charAt(0); + let end = this.buffer.indexOf(quote, this.pos + 1); + if (quote === "'") { + while (end !== -1 && this.buffer[end + 1] === "'") + end = this.buffer.indexOf("'", end + 2); + } + else { + // double-quote + while (end !== -1) { + let n = 0; + while (this.buffer[end - 1 - n] === '\\') + n += 1; + if (n % 2 === 0) + break; + end = this.buffer.indexOf('"', end + 1); + } + } + // Only looking for newlines within the quotes + const qb = this.buffer.substring(0, end); + let nl = qb.indexOf('\n', this.pos); + if (nl !== -1) { + while (nl !== -1) { + const cs = this.continueScalar(nl + 1); + if (cs === -1) + break; + nl = qb.indexOf('\n', cs); + } + if (nl !== -1) { + // this is an error caused by an unexpected unindent + end = nl - (qb[nl - 1] === '\r' ? 2 : 1); + } + } + if (end === -1) { + if (!this.atEnd) + return this.setNext('quoted-scalar'); + end = this.buffer.length; + } + yield* this.pushToIndex(end + 1, false); + return this.flowLevel ? 'flow' : 'doc'; + } + *parseBlockScalarHeader() { + this.blockScalarIndent = -1; + this.blockScalarKeep = false; + let i = this.pos; + while (true) { + const ch = this.buffer[++i]; + if (ch === '+') + this.blockScalarKeep = true; + else if (ch > '0' && ch <= '9') + this.blockScalarIndent = Number(ch) - 1; + else if (ch !== '-') + break; + } + return yield* this.pushUntil(ch => isEmpty(ch) || ch === '#'); + } + *parseBlockScalar() { + let nl = this.pos - 1; // may be -1 if this.pos === 0 + let indent = 0; + let ch; + loop: for (let i = this.pos; (ch = this.buffer[i]); ++i) { + switch (ch) { + case ' ': + indent += 1; + break; + case '\n': + nl = i; + indent = 0; + break; + case '\r': { + const next = this.buffer[i + 1]; + if (!next && !this.atEnd) + return this.setNext('block-scalar'); + if (next === '\n') + break; + } // fallthrough + default: + break loop; + } + } + if (!ch && !this.atEnd) + return this.setNext('block-scalar'); + if (indent >= this.indentNext) { + if (this.blockScalarIndent === -1) + this.indentNext = indent; + else { + this.indentNext = + this.blockScalarIndent + (this.indentNext === 0 ? 1 : this.indentNext); + } + do { + const cs = this.continueScalar(nl + 1); + if (cs === -1) + break; + nl = this.buffer.indexOf('\n', cs); + } while (nl !== -1); + if (nl === -1) { + if (!this.atEnd) + return this.setNext('block-scalar'); + nl = this.buffer.length; + } + } + // Trailing insufficiently indented tabs are invalid. + // To catch that during parsing, we include them in the block scalar value. + let i = nl + 1; + ch = this.buffer[i]; + while (ch === ' ') + ch = this.buffer[++i]; + if (ch === '\t') { + while (ch === '\t' || ch === ' ' || ch === '\r' || ch === '\n') + ch = this.buffer[++i]; + nl = i - 1; + } + else if (!this.blockScalarKeep) { + do { + let i = nl - 1; + let ch = this.buffer[i]; + if (ch === '\r') + ch = this.buffer[--i]; + const lastChar = i; // Drop the line if last char not more indented + while (ch === ' ') + ch = this.buffer[--i]; + if (ch === '\n' && i >= this.pos && i + 1 + indent > lastChar) + nl = i; + else + break; + } while (true); + } + yield cst.SCALAR; + yield* this.pushToIndex(nl + 1, true); + return yield* this.parseLineStart(); + } + *parsePlainScalar() { + const inFlow = this.flowLevel > 0; + let end = this.pos - 1; + let i = this.pos - 1; + let ch; + while ((ch = this.buffer[++i])) { + if (ch === ':') { + const next = this.buffer[i + 1]; + if (isEmpty(next) || (inFlow && flowIndicatorChars.has(next))) + break; + end = i; + } + else if (isEmpty(ch)) { + let next = this.buffer[i + 1]; + if (ch === '\r') { + if (next === '\n') { + i += 1; + ch = '\n'; + next = this.buffer[i + 1]; + } + else + end = i; + } + if (next === '#' || (inFlow && flowIndicatorChars.has(next))) + break; + if (ch === '\n') { + const cs = this.continueScalar(i + 1); + if (cs === -1) + break; + i = Math.max(i, cs - 2); // to advance, but still account for ' #' + } + } + else { + if (inFlow && flowIndicatorChars.has(ch)) + break; + end = i; + } + } + if (!ch && !this.atEnd) + return this.setNext('plain-scalar'); + yield cst.SCALAR; + yield* this.pushToIndex(end + 1, true); + return inFlow ? 'flow' : 'doc'; + } + *pushCount(n) { + if (n > 0) { + yield this.buffer.substr(this.pos, n); + this.pos += n; + return n; + } + return 0; + } + *pushToIndex(i, allowEmpty) { + const s = this.buffer.slice(this.pos, i); + if (s) { + yield s; + this.pos += s.length; + return s.length; + } + else if (allowEmpty) + yield ''; + return 0; + } + *pushIndicators() { + let n = 0; + loop: while (true) { + switch (this.charAt(0)) { + case '!': + n += yield* this.pushTag(); + n += yield* this.pushSpaces(true); + continue loop; + case '&': + n += yield* this.pushUntil(isNotAnchorChar); + n += yield* this.pushSpaces(true); + continue loop; + case '-': // this is an error + case '?': // this is an error outside flow collections + case ':': { + const inFlow = this.flowLevel > 0; + const ch1 = this.charAt(1); + if (isEmpty(ch1) || (inFlow && flowIndicatorChars.has(ch1))) { + if (!inFlow) + this.indentNext = this.indentValue + 1; + else if (this.flowKey) + this.flowKey = false; + n += yield* this.pushCount(1); + n += yield* this.pushSpaces(true); + continue loop; + } + } + } + break loop; + } + return n; + } + *pushTag() { + if (this.charAt(1) === '<') { + let i = this.pos + 2; + let ch = this.buffer[i]; + while (!isEmpty(ch) && ch !== '>') + ch = this.buffer[++i]; + return yield* this.pushToIndex(ch === '>' ? i + 1 : i, false); + } + else { + let i = this.pos + 1; + let ch = this.buffer[i]; + while (ch) { + if (tagChars.has(ch)) + ch = this.buffer[++i]; + else if (ch === '%' && + hexDigits.has(this.buffer[i + 1]) && + hexDigits.has(this.buffer[i + 2])) { + ch = this.buffer[(i += 3)]; + } + else + break; + } + return yield* this.pushToIndex(i, false); + } + } + *pushNewline() { + const ch = this.buffer[this.pos]; + if (ch === '\n') + return yield* this.pushCount(1); + else if (ch === '\r' && this.charAt(1) === '\n') + return yield* this.pushCount(2); + else + return 0; + } + *pushSpaces(allowTabs) { + let i = this.pos - 1; + let ch; + do { + ch = this.buffer[++i]; + } while (ch === ' ' || (allowTabs && ch === '\t')); + const n = i - this.pos; + if (n > 0) { + yield this.buffer.substr(this.pos, n); + this.pos = i; + } + return n; + } + *pushUntil(test) { + let i = this.pos; + let ch = this.buffer[i]; + while (!test(ch)) + ch = this.buffer[++i]; + return yield* this.pushToIndex(i, false); + } +} + +exports.Lexer = Lexer; diff --git a/bundled-addons/pithos/vendor/yaml/lib/parse/line-counter.js b/bundled-addons/pithos/vendor/yaml/lib/parse/line-counter.js new file mode 100644 index 00000000..0e7383bd --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/parse/line-counter.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Tracks newlines during parsing in order to provide an efficient API for + * determining the one-indexed `{ line, col }` position for any offset + * within the input. + */ +class LineCounter { + constructor() { + this.lineStarts = []; + /** + * Should be called in ascending order. Otherwise, call + * `lineCounter.lineStarts.sort()` before calling `linePos()`. + */ + this.addNewLine = (offset) => this.lineStarts.push(offset); + /** + * Performs a binary search and returns the 1-indexed { line, col } + * position of `offset`. If `line === 0`, `addNewLine` has never been + * called or `offset` is before the first known newline. + */ + this.linePos = (offset) => { + let low = 0; + let high = this.lineStarts.length; + while (low < high) { + const mid = (low + high) >> 1; // Math.floor((low + high) / 2) + if (this.lineStarts[mid] < offset) + low = mid + 1; + else + high = mid; + } + if (this.lineStarts[low] === offset) + return { line: low + 1, col: 1 }; + if (low === 0) + return { line: 0, col: offset }; + const start = this.lineStarts[low - 1]; + return { line: low, col: offset - start + 1 }; + }; + } +} + +exports.LineCounter = LineCounter; diff --git a/bundled-addons/pithos/vendor/yaml/lib/parse/parser.js b/bundled-addons/pithos/vendor/yaml/lib/parse/parser.js new file mode 100644 index 00000000..88ba5349 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/parse/parser.js @@ -0,0 +1,980 @@ +'use strict'; + +var node_process = require('process'); +var cst = require('./cst.js'); +var lexer = require('./lexer.js'); + +function includesToken(list, type) { + for (let i = 0; i < list.length; ++i) + if (list[i].type === type) + return true; + return false; +} +function findNonEmptyIndex(list) { + for (let i = 0; i < list.length; ++i) { + switch (list[i].type) { + case 'space': + case 'comment': + case 'newline': + break; + default: + return i; + } + } + return -1; +} +function isFlowToken(token) { + switch (token?.type) { + case 'alias': + case 'scalar': + case 'single-quoted-scalar': + case 'double-quoted-scalar': + case 'flow-collection': + return true; + default: + return false; + } +} +function getPrevProps(parent) { + switch (parent.type) { + case 'document': + return parent.start; + case 'block-map': { + const it = parent.items[parent.items.length - 1]; + return it.sep ?? it.start; + } + case 'block-seq': + return parent.items[parent.items.length - 1].start; + /* istanbul ignore next should not happen */ + default: + return []; + } +} +/** Note: May modify input array */ +function getFirstKeyStartProps(prev) { + if (prev.length === 0) + return []; + let i = prev.length; + loop: while (--i >= 0) { + switch (prev[i].type) { + case 'doc-start': + case 'explicit-key-ind': + case 'map-value-ind': + case 'seq-item-ind': + case 'newline': + break loop; + } + } + while (prev[++i]?.type === 'space') { + /* loop */ + } + return prev.splice(i, prev.length); +} +function arrayPushArray(target, source) { + // May exhaust call stack with large `source` array + if (source.length < 1e5) + Array.prototype.push.apply(target, source); + else + for (let i = 0; i < source.length; ++i) + target.push(source[i]); +} +function fixFlowSeqItems(fc) { + if (fc.start.type === 'flow-seq-start') { + for (const it of fc.items) { + if (it.sep && + !it.value && + !includesToken(it.start, 'explicit-key-ind') && + !includesToken(it.sep, 'map-value-ind')) { + if (it.key) + it.value = it.key; + delete it.key; + if (isFlowToken(it.value)) { + if (it.value.end) + arrayPushArray(it.value.end, it.sep); + else + it.value.end = it.sep; + } + else + arrayPushArray(it.start, it.sep); + delete it.sep; + } + } + } +} +/** + * A YAML concrete syntax tree (CST) parser + * + * ```ts + * const src: string = ... + * for (const token of new Parser().parse(src)) { + * // token: Token + * } + * ``` + * + * To use the parser with a user-provided lexer: + * + * ```ts + * function* parse(source: string, lexer: Lexer) { + * const parser = new Parser() + * for (const lexeme of lexer.lex(source)) + * yield* parser.next(lexeme) + * yield* parser.end() + * } + * + * const src: string = ... + * const lexer = new Lexer() + * for (const token of parse(src, lexer)) { + * // token: Token + * } + * ``` + */ +class Parser { + /** + * @param onNewLine - If defined, called separately with the start position of + * each new line (in `parse()`, including the start of input). + */ + constructor(onNewLine) { + /** If true, space and sequence indicators count as indentation */ + this.atNewLine = true; + /** If true, next token is a scalar value */ + this.atScalar = false; + /** Current indentation level */ + this.indent = 0; + /** Current offset since the start of parsing */ + this.offset = 0; + /** On the same line with a block map key */ + this.onKeyLine = false; + /** Top indicates the node that's currently being built */ + this.stack = []; + /** The source of the current token, set in parse() */ + this.source = ''; + /** The type of the current token, set in parse() */ + this.type = ''; + // Must be defined after `next()` + this.lexer = new lexer.Lexer(); + this.onNewLine = onNewLine; + } + /** + * Parse `source` as a YAML stream. + * If `incomplete`, a part of the last line may be left as a buffer for the next call. + * + * Errors are not thrown, but yielded as `{ type: 'error', message }` tokens. + * + * @returns A generator of tokens representing each directive, document, and other structure. + */ + *parse(source, incomplete = false) { + if (this.onNewLine && this.offset === 0) + this.onNewLine(0); + for (const lexeme of this.lexer.lex(source, incomplete)) + yield* this.next(lexeme); + if (!incomplete) + yield* this.end(); + } + /** + * Advance the parser by the `source` of one lexical token. + */ + *next(source) { + this.source = source; + if (node_process.env.LOG_TOKENS) + console.log('|', cst.prettyToken(source)); + if (this.atScalar) { + this.atScalar = false; + yield* this.step(); + this.offset += source.length; + return; + } + const type = cst.tokenType(source); + if (!type) { + const message = `Not a YAML token: ${source}`; + yield* this.pop({ type: 'error', offset: this.offset, message, source }); + this.offset += source.length; + } + else if (type === 'scalar') { + this.atNewLine = false; + this.atScalar = true; + this.type = 'scalar'; + } + else { + this.type = type; + yield* this.step(); + switch (type) { + case 'newline': + this.atNewLine = true; + this.indent = 0; + if (this.onNewLine) + this.onNewLine(this.offset + source.length); + break; + case 'space': + if (this.atNewLine && source[0] === ' ') + this.indent += source.length; + break; + case 'explicit-key-ind': + case 'map-value-ind': + case 'seq-item-ind': + if (this.atNewLine) + this.indent += source.length; + break; + case 'doc-mode': + case 'flow-error-end': + return; + default: + this.atNewLine = false; + } + this.offset += source.length; + } + } + /** Call at end of input to push out any remaining constructions */ + *end() { + while (this.stack.length > 0) + yield* this.pop(); + } + get sourceToken() { + const st = { + type: this.type, + offset: this.offset, + indent: this.indent, + source: this.source + }; + return st; + } + *step() { + const top = this.peek(1); + if (this.type === 'doc-end' && top?.type !== 'doc-end') { + while (this.stack.length > 0) + yield* this.pop(); + this.stack.push({ + type: 'doc-end', + offset: this.offset, + source: this.source + }); + return; + } + if (!top) + return yield* this.stream(); + switch (top.type) { + case 'document': + return yield* this.document(top); + case 'alias': + case 'scalar': + case 'single-quoted-scalar': + case 'double-quoted-scalar': + return yield* this.scalar(top); + case 'block-scalar': + return yield* this.blockScalar(top); + case 'block-map': + return yield* this.blockMap(top); + case 'block-seq': + return yield* this.blockSequence(top); + case 'flow-collection': + return yield* this.flowCollection(top); + case 'doc-end': + return yield* this.documentEnd(top); + } + /* istanbul ignore next should not happen */ + yield* this.pop(); + } + peek(n) { + return this.stack[this.stack.length - n]; + } + *pop(error) { + const token = error ?? this.stack.pop(); + /* istanbul ignore if should not happen */ + if (!token) { + const message = 'Tried to pop an empty stack'; + yield { type: 'error', offset: this.offset, source: '', message }; + } + else if (this.stack.length === 0) { + yield token; + } + else { + const top = this.peek(1); + if (token.type === 'block-scalar') { + // Block scalars use their parent rather than header indent + token.indent = 'indent' in top ? top.indent : 0; + } + else if (token.type === 'flow-collection' && top.type === 'document') { + // Ignore all indent for top-level flow collections + token.indent = 0; + } + if (token.type === 'flow-collection') + fixFlowSeqItems(token); + switch (top.type) { + case 'document': + top.value = token; + break; + case 'block-scalar': + top.props.push(token); // error + break; + case 'block-map': { + const it = top.items[top.items.length - 1]; + if (it.value) { + top.items.push({ start: [], key: token, sep: [] }); + this.onKeyLine = true; + return; + } + else if (it.sep) { + it.value = token; + } + else { + Object.assign(it, { key: token, sep: [] }); + this.onKeyLine = !it.explicitKey; + return; + } + break; + } + case 'block-seq': { + const it = top.items[top.items.length - 1]; + if (it.value) + top.items.push({ start: [], value: token }); + else + it.value = token; + break; + } + case 'flow-collection': { + const it = top.items[top.items.length - 1]; + if (!it || it.value) + top.items.push({ start: [], key: token, sep: [] }); + else if (it.sep) + it.value = token; + else + Object.assign(it, { key: token, sep: [] }); + return; + } + /* istanbul ignore next should not happen */ + default: + yield* this.pop(); + yield* this.pop(token); + } + if ((top.type === 'document' || + top.type === 'block-map' || + top.type === 'block-seq') && + (token.type === 'block-map' || token.type === 'block-seq')) { + const last = token.items[token.items.length - 1]; + if (last && + !last.sep && + !last.value && + last.start.length > 0 && + findNonEmptyIndex(last.start) === -1 && + (token.indent === 0 || + last.start.every(st => st.type !== 'comment' || st.indent < token.indent))) { + if (top.type === 'document') + top.end = last.start; + else + top.items.push({ start: last.start }); + token.items.splice(-1, 1); + } + } + } + } + *stream() { + switch (this.type) { + case 'directive-line': + yield { type: 'directive', offset: this.offset, source: this.source }; + return; + case 'byte-order-mark': + case 'space': + case 'comment': + case 'newline': + yield this.sourceToken; + return; + case 'doc-mode': + case 'doc-start': { + const doc = { + type: 'document', + offset: this.offset, + start: [] + }; + if (this.type === 'doc-start') + doc.start.push(this.sourceToken); + this.stack.push(doc); + return; + } + } + yield { + type: 'error', + offset: this.offset, + message: `Unexpected ${this.type} token in YAML stream`, + source: this.source + }; + } + *document(doc) { + if (doc.value) + return yield* this.lineEnd(doc); + switch (this.type) { + case 'doc-start': { + if (findNonEmptyIndex(doc.start) !== -1) { + yield* this.pop(); + yield* this.step(); + } + else + doc.start.push(this.sourceToken); + return; + } + case 'anchor': + case 'tag': + case 'space': + case 'comment': + case 'newline': + doc.start.push(this.sourceToken); + return; + } + const bv = this.startBlockValue(doc); + if (bv) + this.stack.push(bv); + else { + yield { + type: 'error', + offset: this.offset, + message: `Unexpected ${this.type} token in YAML document`, + source: this.source + }; + } + } + *scalar(scalar) { + if (this.type === 'map-value-ind') { + const prev = getPrevProps(this.peek(2)); + const start = getFirstKeyStartProps(prev); + let sep; + if (scalar.end) { + sep = scalar.end; + sep.push(this.sourceToken); + delete scalar.end; + } + else + sep = [this.sourceToken]; + const map = { + type: 'block-map', + offset: scalar.offset, + indent: scalar.indent, + items: [{ start, key: scalar, sep }] + }; + this.onKeyLine = true; + this.stack[this.stack.length - 1] = map; + } + else + yield* this.lineEnd(scalar); + } + *blockScalar(scalar) { + switch (this.type) { + case 'space': + case 'comment': + case 'newline': + scalar.props.push(this.sourceToken); + return; + case 'scalar': + scalar.source = this.source; + // block-scalar source includes trailing newline + this.atNewLine = true; + this.indent = 0; + if (this.onNewLine) { + let nl = this.source.indexOf('\n') + 1; + while (nl !== 0) { + this.onNewLine(this.offset + nl); + nl = this.source.indexOf('\n', nl) + 1; + } + } + yield* this.pop(); + break; + /* istanbul ignore next should not happen */ + default: + yield* this.pop(); + yield* this.step(); + } + } + *blockMap(map) { + const it = map.items[map.items.length - 1]; + // it.sep is true-ish if pair already has key or : separator + switch (this.type) { + case 'newline': + this.onKeyLine = false; + if (it.value) { + const end = 'end' in it.value ? it.value.end : undefined; + const last = Array.isArray(end) ? end[end.length - 1] : undefined; + if (last?.type === 'comment') + end?.push(this.sourceToken); + else + map.items.push({ start: [this.sourceToken] }); + } + else if (it.sep) { + it.sep.push(this.sourceToken); + } + else { + it.start.push(this.sourceToken); + } + return; + case 'space': + case 'comment': + if (it.value) { + map.items.push({ start: [this.sourceToken] }); + } + else if (it.sep) { + it.sep.push(this.sourceToken); + } + else { + if (this.atIndentedComment(it.start, map.indent)) { + const prev = map.items[map.items.length - 2]; + const end = prev?.value?.end; + if (Array.isArray(end)) { + arrayPushArray(end, it.start); + end.push(this.sourceToken); + map.items.pop(); + return; + } + } + it.start.push(this.sourceToken); + } + return; + } + if (this.indent >= map.indent) { + const atMapIndent = !this.onKeyLine && this.indent === map.indent; + const atNextItem = atMapIndent && + (it.sep || it.explicitKey) && + this.type !== 'seq-item-ind'; + // For empty nodes, assign newline-separated not indented empty tokens to following node + let start = []; + if (atNextItem && it.sep && !it.value) { + const nl = []; + for (let i = 0; i < it.sep.length; ++i) { + const st = it.sep[i]; + switch (st.type) { + case 'newline': + nl.push(i); + break; + case 'space': + break; + case 'comment': + if (st.indent > map.indent) + nl.length = 0; + break; + default: + nl.length = 0; + } + } + if (nl.length >= 2) + start = it.sep.splice(nl[1]); + } + switch (this.type) { + case 'anchor': + case 'tag': + if (atNextItem || it.value) { + start.push(this.sourceToken); + map.items.push({ start }); + this.onKeyLine = true; + } + else if (it.sep) { + it.sep.push(this.sourceToken); + } + else { + it.start.push(this.sourceToken); + } + return; + case 'explicit-key-ind': + if (!it.sep && !it.explicitKey) { + it.start.push(this.sourceToken); + it.explicitKey = true; + } + else if (atNextItem || it.value) { + start.push(this.sourceToken); + map.items.push({ start, explicitKey: true }); + } + else { + this.stack.push({ + type: 'block-map', + offset: this.offset, + indent: this.indent, + items: [{ start: [this.sourceToken], explicitKey: true }] + }); + } + this.onKeyLine = true; + return; + case 'map-value-ind': + if (it.explicitKey) { + if (!it.sep) { + if (includesToken(it.start, 'newline')) { + Object.assign(it, { key: null, sep: [this.sourceToken] }); + } + else { + const start = getFirstKeyStartProps(it.start); + this.stack.push({ + type: 'block-map', + offset: this.offset, + indent: this.indent, + items: [{ start, key: null, sep: [this.sourceToken] }] + }); + } + } + else if (it.value) { + map.items.push({ start: [], key: null, sep: [this.sourceToken] }); + } + else if (includesToken(it.sep, 'map-value-ind')) { + this.stack.push({ + type: 'block-map', + offset: this.offset, + indent: this.indent, + items: [{ start, key: null, sep: [this.sourceToken] }] + }); + } + else if (isFlowToken(it.key) && + !includesToken(it.sep, 'newline')) { + const start = getFirstKeyStartProps(it.start); + const key = it.key; + const sep = it.sep; + sep.push(this.sourceToken); + // @ts-expect-error type guard is wrong here + delete it.key; + // @ts-expect-error type guard is wrong here + delete it.sep; + this.stack.push({ + type: 'block-map', + offset: this.offset, + indent: this.indent, + items: [{ start, key, sep }] + }); + } + else if (start.length > 0) { + // Not actually at next item + it.sep = it.sep.concat(start, this.sourceToken); + } + else { + it.sep.push(this.sourceToken); + } + } + else { + if (!it.sep) { + Object.assign(it, { key: null, sep: [this.sourceToken] }); + } + else if (it.value || atNextItem) { + map.items.push({ start, key: null, sep: [this.sourceToken] }); + } + else if (includesToken(it.sep, 'map-value-ind')) { + this.stack.push({ + type: 'block-map', + offset: this.offset, + indent: this.indent, + items: [{ start: [], key: null, sep: [this.sourceToken] }] + }); + } + else { + it.sep.push(this.sourceToken); + } + } + this.onKeyLine = true; + return; + case 'alias': + case 'scalar': + case 'single-quoted-scalar': + case 'double-quoted-scalar': { + const fs = this.flowScalar(this.type); + if (atNextItem || it.value) { + map.items.push({ start, key: fs, sep: [] }); + this.onKeyLine = true; + } + else if (it.sep) { + this.stack.push(fs); + } + else { + Object.assign(it, { key: fs, sep: [] }); + this.onKeyLine = true; + } + return; + } + default: { + const bv = this.startBlockValue(map); + if (bv) { + if (bv.type === 'block-seq') { + if (!it.explicitKey && + it.sep && + !includesToken(it.sep, 'newline')) { + yield* this.pop({ + type: 'error', + offset: this.offset, + message: 'Unexpected block-seq-ind on same line with key', + source: this.source + }); + return; + } + } + else if (atMapIndent) { + map.items.push({ start }); + } + this.stack.push(bv); + return; + } + } + } + } + yield* this.pop(); + yield* this.step(); + } + *blockSequence(seq) { + const it = seq.items[seq.items.length - 1]; + switch (this.type) { + case 'newline': + if (it.value) { + const end = 'end' in it.value ? it.value.end : undefined; + const last = Array.isArray(end) ? end[end.length - 1] : undefined; + if (last?.type === 'comment') + end?.push(this.sourceToken); + else + seq.items.push({ start: [this.sourceToken] }); + } + else + it.start.push(this.sourceToken); + return; + case 'space': + case 'comment': + if (it.value) + seq.items.push({ start: [this.sourceToken] }); + else { + if (this.atIndentedComment(it.start, seq.indent)) { + const prev = seq.items[seq.items.length - 2]; + const end = prev?.value?.end; + if (Array.isArray(end)) { + arrayPushArray(end, it.start); + end.push(this.sourceToken); + seq.items.pop(); + return; + } + } + it.start.push(this.sourceToken); + } + return; + case 'anchor': + case 'tag': + if (it.value || this.indent <= seq.indent) + break; + it.start.push(this.sourceToken); + return; + case 'seq-item-ind': + if (this.indent !== seq.indent) + break; + if (it.value || includesToken(it.start, 'seq-item-ind')) + seq.items.push({ start: [this.sourceToken] }); + else + it.start.push(this.sourceToken); + return; + } + if (this.indent > seq.indent) { + const bv = this.startBlockValue(seq); + if (bv) { + this.stack.push(bv); + return; + } + } + yield* this.pop(); + yield* this.step(); + } + *flowCollection(fc) { + const it = fc.items[fc.items.length - 1]; + if (this.type === 'flow-error-end') { + let top; + do { + yield* this.pop(); + top = this.peek(1); + } while (top?.type === 'flow-collection'); + } + else if (fc.end.length === 0) { + switch (this.type) { + case 'comma': + case 'explicit-key-ind': + if (!it || it.sep) + fc.items.push({ start: [this.sourceToken] }); + else + it.start.push(this.sourceToken); + return; + case 'map-value-ind': + if (!it || it.value) + fc.items.push({ start: [], key: null, sep: [this.sourceToken] }); + else if (it.sep) + it.sep.push(this.sourceToken); + else + Object.assign(it, { key: null, sep: [this.sourceToken] }); + return; + case 'space': + case 'comment': + case 'newline': + case 'anchor': + case 'tag': + if (!it || it.value) + fc.items.push({ start: [this.sourceToken] }); + else if (it.sep) + it.sep.push(this.sourceToken); + else + it.start.push(this.sourceToken); + return; + case 'alias': + case 'scalar': + case 'single-quoted-scalar': + case 'double-quoted-scalar': { + const fs = this.flowScalar(this.type); + if (!it || it.value) + fc.items.push({ start: [], key: fs, sep: [] }); + else if (it.sep) + this.stack.push(fs); + else + Object.assign(it, { key: fs, sep: [] }); + return; + } + case 'flow-map-end': + case 'flow-seq-end': + fc.end.push(this.sourceToken); + return; + } + const bv = this.startBlockValue(fc); + /* istanbul ignore else should not happen */ + if (bv) + this.stack.push(bv); + else { + yield* this.pop(); + yield* this.step(); + } + } + else { + const parent = this.peek(2); + if (parent.type === 'block-map' && + ((this.type === 'map-value-ind' && parent.indent === fc.indent) || + (this.type === 'newline' && + !parent.items[parent.items.length - 1].sep))) { + yield* this.pop(); + yield* this.step(); + } + else if (this.type === 'map-value-ind' && + parent.type !== 'flow-collection') { + const prev = getPrevProps(parent); + const start = getFirstKeyStartProps(prev); + fixFlowSeqItems(fc); + const sep = fc.end.splice(1, fc.end.length); + sep.push(this.sourceToken); + const map = { + type: 'block-map', + offset: fc.offset, + indent: fc.indent, + items: [{ start, key: fc, sep }] + }; + this.onKeyLine = true; + this.stack[this.stack.length - 1] = map; + } + else { + yield* this.lineEnd(fc); + } + } + } + flowScalar(type) { + if (this.onNewLine) { + let nl = this.source.indexOf('\n') + 1; + while (nl !== 0) { + this.onNewLine(this.offset + nl); + nl = this.source.indexOf('\n', nl) + 1; + } + } + return { + type, + offset: this.offset, + indent: this.indent, + source: this.source + }; + } + startBlockValue(parent) { + switch (this.type) { + case 'alias': + case 'scalar': + case 'single-quoted-scalar': + case 'double-quoted-scalar': + return this.flowScalar(this.type); + case 'block-scalar-header': + return { + type: 'block-scalar', + offset: this.offset, + indent: this.indent, + props: [this.sourceToken], + source: '' + }; + case 'flow-map-start': + case 'flow-seq-start': + return { + type: 'flow-collection', + offset: this.offset, + indent: this.indent, + start: this.sourceToken, + items: [], + end: [] + }; + case 'seq-item-ind': + return { + type: 'block-seq', + offset: this.offset, + indent: this.indent, + items: [{ start: [this.sourceToken] }] + }; + case 'explicit-key-ind': { + this.onKeyLine = true; + const prev = getPrevProps(parent); + const start = getFirstKeyStartProps(prev); + start.push(this.sourceToken); + return { + type: 'block-map', + offset: this.offset, + indent: this.indent, + items: [{ start, explicitKey: true }] + }; + } + case 'map-value-ind': { + this.onKeyLine = true; + const prev = getPrevProps(parent); + const start = getFirstKeyStartProps(prev); + return { + type: 'block-map', + offset: this.offset, + indent: this.indent, + items: [{ start, key: null, sep: [this.sourceToken] }] + }; + } + } + return null; + } + atIndentedComment(start, indent) { + if (this.type !== 'comment') + return false; + if (this.indent <= indent) + return false; + return start.every(st => st.type === 'newline' || st.type === 'space'); + } + *documentEnd(docEnd) { + if (this.type !== 'doc-mode') { + if (docEnd.end) + docEnd.end.push(this.sourceToken); + else + docEnd.end = [this.sourceToken]; + if (this.type === 'newline') + yield* this.pop(); + } + } + *lineEnd(token) { + switch (this.type) { + case 'comma': + case 'doc-start': + case 'doc-end': + case 'flow-seq-end': + case 'flow-map-end': + case 'map-value-ind': + yield* this.pop(); + yield* this.step(); + break; + case 'newline': + this.onKeyLine = false; + // fallthrough + case 'space': + case 'comment': + default: + // all other values are errors + if (token.end) + token.end.push(this.sourceToken); + else + token.end = [this.sourceToken]; + if (this.type === 'newline') + yield* this.pop(); + } + } +} + +exports.Parser = Parser; diff --git a/bundled-addons/pithos/vendor/yaml/lib/public-api.js b/bundled-addons/pithos/vendor/yaml/lib/public-api.js new file mode 100644 index 00000000..db76cefd --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/public-api.js @@ -0,0 +1,107 @@ +'use strict'; + +var composer = require('./compose/composer.js'); +var Document = require('./doc/Document.js'); +var errors = require('./errors.js'); +var log = require('./log.js'); +var identity = require('./nodes/identity.js'); +var lineCounter = require('./parse/line-counter.js'); +var parser = require('./parse/parser.js'); + +function parseOptions(options) { + const prettyErrors = options.prettyErrors !== false; + const lineCounter$1 = options.lineCounter || (prettyErrors && new lineCounter.LineCounter()) || null; + return { lineCounter: lineCounter$1, prettyErrors }; +} +/** + * Parse the input as a stream of YAML documents. + * + * Documents should be separated from each other by `...` or `---` marker lines. + * + * @returns If an empty `docs` array is returned, it will be of type + * EmptyStream and contain additional stream information. In + * TypeScript, you should use `'empty' in docs` as a type guard for it. + */ +function parseAllDocuments(source, options = {}) { + const { lineCounter, prettyErrors } = parseOptions(options); + const parser$1 = new parser.Parser(lineCounter?.addNewLine); + const composer$1 = new composer.Composer(options); + const docs = Array.from(composer$1.compose(parser$1.parse(source))); + if (prettyErrors && lineCounter) + for (const doc of docs) { + doc.errors.forEach(errors.prettifyError(source, lineCounter)); + doc.warnings.forEach(errors.prettifyError(source, lineCounter)); + } + if (docs.length > 0) + return docs; + return Object.assign([], { empty: true }, composer$1.streamInfo()); +} +/** Parse an input string into a single YAML.Document */ +function parseDocument(source, options = {}) { + const { lineCounter, prettyErrors } = parseOptions(options); + const parser$1 = new parser.Parser(lineCounter?.addNewLine); + const composer$1 = new composer.Composer(options); + // `doc` is always set by compose.end(true) at the very latest + let doc = null; + for (const _doc of composer$1.compose(parser$1.parse(source), true, source.length)) { + if (!doc) + doc = _doc; + else if (doc.options.logLevel !== 'silent') { + doc.errors.push(new errors.YAMLParseError(_doc.range.slice(0, 2), 'MULTIPLE_DOCS', 'Source contains multiple documents; please use YAML.parseAllDocuments()')); + break; + } + } + if (prettyErrors && lineCounter) { + doc.errors.forEach(errors.prettifyError(source, lineCounter)); + doc.warnings.forEach(errors.prettifyError(source, lineCounter)); + } + return doc; +} +function parse(src, reviver, options) { + let _reviver = undefined; + if (typeof reviver === 'function') { + _reviver = reviver; + } + else if (options === undefined && reviver && typeof reviver === 'object') { + options = reviver; + } + const doc = parseDocument(src, options); + if (!doc) + return null; + doc.warnings.forEach(warning => log.warn(doc.options.logLevel, warning)); + if (doc.errors.length > 0) { + if (doc.options.logLevel !== 'silent') + throw doc.errors[0]; + else + doc.errors = []; + } + return doc.toJS(Object.assign({ reviver: _reviver }, options)); +} +function stringify(value, replacer, options) { + let _replacer = null; + if (typeof replacer === 'function' || Array.isArray(replacer)) { + _replacer = replacer; + } + else if (options === undefined && replacer) { + options = replacer; + } + if (typeof options === 'string') + options = options.length; + if (typeof options === 'number') { + const indent = Math.round(options); + options = indent < 1 ? undefined : indent > 8 ? { indent: 8 } : { indent }; + } + if (value === undefined) { + const { keepUndefined } = options ?? replacer ?? {}; + if (!keepUndefined) + return undefined; + } + if (identity.isDocument(value) && !_replacer) + return value.toString(options); + return new Document.Document(value, _replacer, options).toString(options); +} + +exports.parse = parse; +exports.parseAllDocuments = parseAllDocuments; +exports.parseDocument = parseDocument; +exports.stringify = stringify; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/Schema.js b/bundled-addons/pithos/vendor/yaml/lib/schema/Schema.js new file mode 100644 index 00000000..39265470 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/Schema.js @@ -0,0 +1,39 @@ +'use strict'; + +var identity = require('../nodes/identity.js'); +var map = require('./common/map.js'); +var seq = require('./common/seq.js'); +var string = require('./common/string.js'); +var tags = require('./tags.js'); + +const sortMapEntriesByKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0; +class Schema { + constructor({ compat, customTags, merge, resolveKnownTags, schema, sortMapEntries, toStringDefaults }) { + this.compat = Array.isArray(compat) + ? tags.getTags(compat, 'compat') + : compat + ? tags.getTags(null, compat) + : null; + this.name = (typeof schema === 'string' && schema) || 'core'; + this.knownTags = resolveKnownTags ? tags.coreKnownTags : {}; + this.tags = tags.getTags(customTags, this.name, merge); + this.toStringOptions = toStringDefaults ?? null; + Object.defineProperty(this, identity.MAP, { value: map.map }); + Object.defineProperty(this, identity.SCALAR, { value: string.string }); + Object.defineProperty(this, identity.SEQ, { value: seq.seq }); + // Used by createMap() + this.sortMapEntries = + typeof sortMapEntries === 'function' + ? sortMapEntries + : sortMapEntries === true + ? sortMapEntriesByKey + : null; + } + clone() { + const copy = Object.create(Schema.prototype, Object.getOwnPropertyDescriptors(this)); + copy.tags = this.tags.slice(); + return copy; + } +} + +exports.Schema = Schema; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/common/map.js b/bundled-addons/pithos/vendor/yaml/lib/schema/common/map.js new file mode 100644 index 00000000..649c3b97 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/common/map.js @@ -0,0 +1,19 @@ +'use strict'; + +var identity = require('../../nodes/identity.js'); +var YAMLMap = require('../../nodes/YAMLMap.js'); + +const map = { + collection: 'map', + default: true, + nodeClass: YAMLMap.YAMLMap, + tag: 'tag:yaml.org,2002:map', + resolve(map, onError) { + if (!identity.isMap(map)) + onError('Expected a mapping for this tag'); + return map; + }, + createNode: (schema, obj, ctx) => YAMLMap.YAMLMap.from(schema, obj, ctx) +}; + +exports.map = map; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/common/null.js b/bundled-addons/pithos/vendor/yaml/lib/schema/common/null.js new file mode 100644 index 00000000..cb353a7a --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/common/null.js @@ -0,0 +1,17 @@ +'use strict'; + +var Scalar = require('../../nodes/Scalar.js'); + +const nullTag = { + identify: value => value == null, + createNode: () => new Scalar.Scalar(null), + default: true, + tag: 'tag:yaml.org,2002:null', + test: /^(?:~|[Nn]ull|NULL)?$/, + resolve: () => new Scalar.Scalar(null), + stringify: ({ source }, ctx) => typeof source === 'string' && nullTag.test.test(source) + ? source + : ctx.options.nullStr +}; + +exports.nullTag = nullTag; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/common/seq.js b/bundled-addons/pithos/vendor/yaml/lib/schema/common/seq.js new file mode 100644 index 00000000..9c54bc96 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/common/seq.js @@ -0,0 +1,19 @@ +'use strict'; + +var identity = require('../../nodes/identity.js'); +var YAMLSeq = require('../../nodes/YAMLSeq.js'); + +const seq = { + collection: 'seq', + default: true, + nodeClass: YAMLSeq.YAMLSeq, + tag: 'tag:yaml.org,2002:seq', + resolve(seq, onError) { + if (!identity.isSeq(seq)) + onError('Expected a sequence for this tag'); + return seq; + }, + createNode: (schema, obj, ctx) => YAMLSeq.YAMLSeq.from(schema, obj, ctx) +}; + +exports.seq = seq; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/common/string.js b/bundled-addons/pithos/vendor/yaml/lib/schema/common/string.js new file mode 100644 index 00000000..76014200 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/common/string.js @@ -0,0 +1,16 @@ +'use strict'; + +var stringifyString = require('../../stringify/stringifyString.js'); + +const string = { + identify: value => typeof value === 'string', + default: true, + tag: 'tag:yaml.org,2002:str', + resolve: str => str, + stringify(item, ctx, onComment, onChompKeep) { + ctx = Object.assign({ actualString: true }, ctx); + return stringifyString.stringifyString(item, ctx, onComment, onChompKeep); + } +}; + +exports.string = string; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/core/bool.js b/bundled-addons/pithos/vendor/yaml/lib/schema/core/bool.js new file mode 100644 index 00000000..4def73c7 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/core/bool.js @@ -0,0 +1,21 @@ +'use strict'; + +var Scalar = require('../../nodes/Scalar.js'); + +const boolTag = { + identify: value => typeof value === 'boolean', + default: true, + tag: 'tag:yaml.org,2002:bool', + test: /^(?:[Tt]rue|TRUE|[Ff]alse|FALSE)$/, + resolve: str => new Scalar.Scalar(str[0] === 't' || str[0] === 'T'), + stringify({ source, value }, ctx) { + if (source && boolTag.test.test(source)) { + const sv = source[0] === 't' || source[0] === 'T'; + if (value === sv) + return source; + } + return value ? ctx.options.trueStr : ctx.options.falseStr; + } +}; + +exports.boolTag = boolTag; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/core/float.js b/bundled-addons/pithos/vendor/yaml/lib/schema/core/float.js new file mode 100644 index 00000000..8756446e --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/core/float.js @@ -0,0 +1,47 @@ +'use strict'; + +var Scalar = require('../../nodes/Scalar.js'); +var stringifyNumber = require('../../stringify/stringifyNumber.js'); + +const floatNaN = { + identify: value => typeof value === 'number', + default: true, + tag: 'tag:yaml.org,2002:float', + test: /^(?:[-+]?\.(?:inf|Inf|INF)|\.nan|\.NaN|\.NAN)$/, + resolve: str => str.slice(-3).toLowerCase() === 'nan' + ? NaN + : str[0] === '-' + ? Number.NEGATIVE_INFINITY + : Number.POSITIVE_INFINITY, + stringify: stringifyNumber.stringifyNumber +}; +const floatExp = { + identify: value => typeof value === 'number', + default: true, + tag: 'tag:yaml.org,2002:float', + format: 'EXP', + test: /^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)[eE][-+]?[0-9]+$/, + resolve: str => parseFloat(str), + stringify(node) { + const num = Number(node.value); + return isFinite(num) ? num.toExponential() : stringifyNumber.stringifyNumber(node); + } +}; +const float = { + identify: value => typeof value === 'number', + default: true, + tag: 'tag:yaml.org,2002:float', + test: /^[-+]?(?:\.[0-9]+|[0-9]+\.[0-9]*)$/, + resolve(str) { + const node = new Scalar.Scalar(parseFloat(str)); + const dot = str.indexOf('.'); + if (dot !== -1 && str[str.length - 1] === '0') + node.minFractionDigits = str.length - dot - 1; + return node; + }, + stringify: stringifyNumber.stringifyNumber +}; + +exports.float = float; +exports.floatExp = floatExp; +exports.floatNaN = floatNaN; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/core/int.js b/bundled-addons/pithos/vendor/yaml/lib/schema/core/int.js new file mode 100644 index 00000000..fe4c9ca1 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/core/int.js @@ -0,0 +1,42 @@ +'use strict'; + +var stringifyNumber = require('../../stringify/stringifyNumber.js'); + +const intIdentify = (value) => typeof value === 'bigint' || Number.isInteger(value); +const intResolve = (str, offset, radix, { intAsBigInt }) => (intAsBigInt ? BigInt(str) : parseInt(str.substring(offset), radix)); +function intStringify(node, radix, prefix) { + const { value } = node; + if (intIdentify(value) && value >= 0) + return prefix + value.toString(radix); + return stringifyNumber.stringifyNumber(node); +} +const intOct = { + identify: value => intIdentify(value) && value >= 0, + default: true, + tag: 'tag:yaml.org,2002:int', + format: 'OCT', + test: /^0o[0-7]+$/, + resolve: (str, _onError, opt) => intResolve(str, 2, 8, opt), + stringify: node => intStringify(node, 8, '0o') +}; +const int = { + identify: intIdentify, + default: true, + tag: 'tag:yaml.org,2002:int', + test: /^[-+]?[0-9]+$/, + resolve: (str, _onError, opt) => intResolve(str, 0, 10, opt), + stringify: stringifyNumber.stringifyNumber +}; +const intHex = { + identify: value => intIdentify(value) && value >= 0, + default: true, + tag: 'tag:yaml.org,2002:int', + format: 'HEX', + test: /^0x[0-9a-fA-F]+$/, + resolve: (str, _onError, opt) => intResolve(str, 2, 16, opt), + stringify: node => intStringify(node, 16, '0x') +}; + +exports.int = int; +exports.intHex = intHex; +exports.intOct = intOct; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/core/schema.js b/bundled-addons/pithos/vendor/yaml/lib/schema/core/schema.js new file mode 100644 index 00000000..6ab87f2c --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/core/schema.js @@ -0,0 +1,25 @@ +'use strict'; + +var map = require('../common/map.js'); +var _null = require('../common/null.js'); +var seq = require('../common/seq.js'); +var string = require('../common/string.js'); +var bool = require('./bool.js'); +var float = require('./float.js'); +var int = require('./int.js'); + +const schema = [ + map.map, + seq.seq, + string.string, + _null.nullTag, + bool.boolTag, + int.intOct, + int.int, + int.intHex, + float.floatNaN, + float.floatExp, + float.float +]; + +exports.schema = schema; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/json/schema.js b/bundled-addons/pithos/vendor/yaml/lib/schema/json/schema.js new file mode 100644 index 00000000..ccb871af --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/json/schema.js @@ -0,0 +1,64 @@ +'use strict'; + +var Scalar = require('../../nodes/Scalar.js'); +var map = require('../common/map.js'); +var seq = require('../common/seq.js'); + +function intIdentify(value) { + return typeof value === 'bigint' || Number.isInteger(value); +} +const stringifyJSON = ({ value }) => JSON.stringify(value); +const jsonScalars = [ + { + identify: value => typeof value === 'string', + default: true, + tag: 'tag:yaml.org,2002:str', + resolve: str => str, + stringify: stringifyJSON + }, + { + identify: value => value == null, + createNode: () => new Scalar.Scalar(null), + default: true, + tag: 'tag:yaml.org,2002:null', + test: /^null$/, + resolve: () => null, + stringify: stringifyJSON + }, + { + identify: value => typeof value === 'boolean', + default: true, + tag: 'tag:yaml.org,2002:bool', + test: /^true$|^false$/, + resolve: str => str === 'true', + stringify: stringifyJSON + }, + { + identify: intIdentify, + default: true, + tag: 'tag:yaml.org,2002:int', + test: /^-?(?:0|[1-9][0-9]*)$/, + resolve: (str, _onError, { intAsBigInt }) => intAsBigInt ? BigInt(str) : parseInt(str, 10), + stringify: ({ value }) => intIdentify(value) ? value.toString() : JSON.stringify(value) + }, + { + identify: value => typeof value === 'number', + default: true, + tag: 'tag:yaml.org,2002:float', + test: /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*)?(?:[eE][-+]?[0-9]+)?$/, + resolve: str => parseFloat(str), + stringify: stringifyJSON + } +]; +const jsonError = { + default: true, + tag: '', + test: /^/, + resolve(str, onError) { + onError(`Unresolved plain scalar ${JSON.stringify(str)}`); + return str; + } +}; +const schema = [map.map, seq.seq].concat(jsonScalars, jsonError); + +exports.schema = schema; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/tags.js b/bundled-addons/pithos/vendor/yaml/lib/schema/tags.js new file mode 100644 index 00000000..bd67d864 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/tags.js @@ -0,0 +1,99 @@ +'use strict'; + +var map = require('./common/map.js'); +var _null = require('./common/null.js'); +var seq = require('./common/seq.js'); +var string = require('./common/string.js'); +var bool = require('./core/bool.js'); +var float = require('./core/float.js'); +var int = require('./core/int.js'); +var schema = require('./core/schema.js'); +var schema$1 = require('./json/schema.js'); +var binary = require('./yaml-1.1/binary.js'); +var merge = require('./yaml-1.1/merge.js'); +var omap = require('./yaml-1.1/omap.js'); +var pairs = require('./yaml-1.1/pairs.js'); +var schema$2 = require('./yaml-1.1/schema.js'); +var set = require('./yaml-1.1/set.js'); +var timestamp = require('./yaml-1.1/timestamp.js'); + +const schemas = new Map([ + ['core', schema.schema], + ['failsafe', [map.map, seq.seq, string.string]], + ['json', schema$1.schema], + ['yaml11', schema$2.schema], + ['yaml-1.1', schema$2.schema] +]); +const tagsByName = { + binary: binary.binary, + bool: bool.boolTag, + float: float.float, + floatExp: float.floatExp, + floatNaN: float.floatNaN, + floatTime: timestamp.floatTime, + int: int.int, + intHex: int.intHex, + intOct: int.intOct, + intTime: timestamp.intTime, + map: map.map, + merge: merge.merge, + null: _null.nullTag, + omap: omap.omap, + pairs: pairs.pairs, + seq: seq.seq, + set: set.set, + timestamp: timestamp.timestamp +}; +const coreKnownTags = { + 'tag:yaml.org,2002:binary': binary.binary, + 'tag:yaml.org,2002:merge': merge.merge, + 'tag:yaml.org,2002:omap': omap.omap, + 'tag:yaml.org,2002:pairs': pairs.pairs, + 'tag:yaml.org,2002:set': set.set, + 'tag:yaml.org,2002:timestamp': timestamp.timestamp +}; +function getTags(customTags, schemaName, addMergeTag) { + const schemaTags = schemas.get(schemaName); + if (schemaTags && !customTags) { + return addMergeTag && !schemaTags.includes(merge.merge) + ? schemaTags.concat(merge.merge) + : schemaTags.slice(); + } + let tags = schemaTags; + if (!tags) { + if (Array.isArray(customTags)) + tags = []; + else { + const keys = Array.from(schemas.keys()) + .filter(key => key !== 'yaml11') + .map(key => JSON.stringify(key)) + .join(', '); + throw new Error(`Unknown schema "${schemaName}"; use one of ${keys} or define customTags array`); + } + } + if (Array.isArray(customTags)) { + for (const tag of customTags) + tags = tags.concat(tag); + } + else if (typeof customTags === 'function') { + tags = customTags(tags.slice()); + } + if (addMergeTag) + tags = tags.concat(merge.merge); + return tags.reduce((tags, tag) => { + const tagObj = typeof tag === 'string' ? tagsByName[tag] : tag; + if (!tagObj) { + const tagName = JSON.stringify(tag); + const keys = Object.keys(tagsByName) + .map(key => JSON.stringify(key)) + .join(', '); + throw new Error(`Unknown custom tag ${tagName}; use one of ${keys}`); + } + if (!tags.includes(tagObj)) + tags.push(tagObj); + return tags; + }, []); +} + +exports.coreKnownTags = coreKnownTags; +exports.getTags = getTags; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/binary.js b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/binary.js new file mode 100644 index 00000000..e529d749 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/binary.js @@ -0,0 +1,70 @@ +'use strict'; + +var node_buffer = require('buffer'); +var Scalar = require('../../nodes/Scalar.js'); +var stringifyString = require('../../stringify/stringifyString.js'); + +const binary = { + identify: value => value instanceof Uint8Array, // Buffer inherits from Uint8Array + default: false, + tag: 'tag:yaml.org,2002:binary', + /** + * Returns a Buffer in node and an Uint8Array in browsers + * + * To use the resulting buffer as an image, you'll want to do something like: + * + * const blob = new Blob([buffer], { type: 'image/jpeg' }) + * document.querySelector('#photo').src = URL.createObjectURL(blob) + */ + resolve(src, onError) { + if (typeof node_buffer.Buffer === 'function') { + return node_buffer.Buffer.from(src, 'base64'); + } + else if (typeof atob === 'function') { + // On IE 11, atob() can't handle newlines + const str = atob(src.replace(/[\n\r]/g, '')); + const buffer = new Uint8Array(str.length); + for (let i = 0; i < str.length; ++i) + buffer[i] = str.charCodeAt(i); + return buffer; + } + else { + onError('This environment does not support reading binary tags; either Buffer or atob is required'); + return src; + } + }, + stringify({ comment, type, value }, ctx, onComment, onChompKeep) { + if (!value) + return ''; + const buf = value; // checked earlier by binary.identify() + let str; + if (typeof node_buffer.Buffer === 'function') { + str = + buf instanceof node_buffer.Buffer + ? buf.toString('base64') + : node_buffer.Buffer.from(buf.buffer).toString('base64'); + } + else if (typeof btoa === 'function') { + let s = ''; + for (let i = 0; i < buf.length; ++i) + s += String.fromCharCode(buf[i]); + str = btoa(s); + } + else { + throw new Error('This environment does not support writing binary tags; either Buffer or btoa is required'); + } + type ?? (type = Scalar.Scalar.BLOCK_LITERAL); + if (type !== Scalar.Scalar.QUOTE_DOUBLE) { + const lineWidth = Math.max(ctx.options.lineWidth - ctx.indent.length, ctx.options.minContentWidth); + const n = Math.ceil(str.length / lineWidth); + const lines = new Array(n); + for (let i = 0, o = 0; i < n; ++i, o += lineWidth) { + lines[i] = str.substr(o, lineWidth); + } + str = lines.join(type === Scalar.Scalar.BLOCK_LITERAL ? '\n' : ' '); + } + return stringifyString.stringifyString({ comment, type, value: str }, ctx, onComment, onChompKeep); + } +}; + +exports.binary = binary; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/bool.js b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/bool.js new file mode 100644 index 00000000..d9879526 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/bool.js @@ -0,0 +1,29 @@ +'use strict'; + +var Scalar = require('../../nodes/Scalar.js'); + +function boolStringify({ value, source }, ctx) { + const boolObj = value ? trueTag : falseTag; + if (source && boolObj.test.test(source)) + return source; + return value ? ctx.options.trueStr : ctx.options.falseStr; +} +const trueTag = { + identify: value => value === true, + default: true, + tag: 'tag:yaml.org,2002:bool', + test: /^(?:Y|y|[Yy]es|YES|[Tt]rue|TRUE|[Oo]n|ON)$/, + resolve: () => new Scalar.Scalar(true), + stringify: boolStringify +}; +const falseTag = { + identify: value => value === false, + default: true, + tag: 'tag:yaml.org,2002:bool', + test: /^(?:N|n|[Nn]o|NO|[Ff]alse|FALSE|[Oo]ff|OFF)$/, + resolve: () => new Scalar.Scalar(false), + stringify: boolStringify +}; + +exports.falseTag = falseTag; +exports.trueTag = trueTag; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/float.js b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/float.js new file mode 100644 index 00000000..39f1eb0c --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/float.js @@ -0,0 +1,50 @@ +'use strict'; + +var Scalar = require('../../nodes/Scalar.js'); +var stringifyNumber = require('../../stringify/stringifyNumber.js'); + +const floatNaN = { + identify: value => typeof value === 'number', + default: true, + tag: 'tag:yaml.org,2002:float', + test: /^(?:[-+]?\.(?:inf|Inf|INF)|\.nan|\.NaN|\.NAN)$/, + resolve: (str) => str.slice(-3).toLowerCase() === 'nan' + ? NaN + : str[0] === '-' + ? Number.NEGATIVE_INFINITY + : Number.POSITIVE_INFINITY, + stringify: stringifyNumber.stringifyNumber +}; +const floatExp = { + identify: value => typeof value === 'number', + default: true, + tag: 'tag:yaml.org,2002:float', + format: 'EXP', + test: /^[-+]?(?:[0-9][0-9_]*)?(?:\.[0-9_]*)?[eE][-+]?[0-9]+$/, + resolve: (str) => parseFloat(str.replace(/_/g, '')), + stringify(node) { + const num = Number(node.value); + return isFinite(num) ? num.toExponential() : stringifyNumber.stringifyNumber(node); + } +}; +const float = { + identify: value => typeof value === 'number', + default: true, + tag: 'tag:yaml.org,2002:float', + test: /^[-+]?(?:[0-9][0-9_]*)?\.[0-9_]*$/, + resolve(str) { + const node = new Scalar.Scalar(parseFloat(str.replace(/_/g, ''))); + const dot = str.indexOf('.'); + if (dot !== -1) { + const f = str.substring(dot + 1).replace(/_/g, ''); + if (f[f.length - 1] === '0') + node.minFractionDigits = f.length; + } + return node; + }, + stringify: stringifyNumber.stringifyNumber +}; + +exports.float = float; +exports.floatExp = floatExp; +exports.floatNaN = floatNaN; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/int.js b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/int.js new file mode 100644 index 00000000..fdf47ca6 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/int.js @@ -0,0 +1,76 @@ +'use strict'; + +var stringifyNumber = require('../../stringify/stringifyNumber.js'); + +const intIdentify = (value) => typeof value === 'bigint' || Number.isInteger(value); +function intResolve(str, offset, radix, { intAsBigInt }) { + const sign = str[0]; + if (sign === '-' || sign === '+') + offset += 1; + str = str.substring(offset).replace(/_/g, ''); + if (intAsBigInt) { + switch (radix) { + case 2: + str = `0b${str}`; + break; + case 8: + str = `0o${str}`; + break; + case 16: + str = `0x${str}`; + break; + } + const n = BigInt(str); + return sign === '-' ? BigInt(-1) * n : n; + } + const n = parseInt(str, radix); + return sign === '-' ? -1 * n : n; +} +function intStringify(node, radix, prefix) { + const { value } = node; + if (intIdentify(value)) { + const str = value.toString(radix); + return value < 0 ? '-' + prefix + str.substr(1) : prefix + str; + } + return stringifyNumber.stringifyNumber(node); +} +const intBin = { + identify: intIdentify, + default: true, + tag: 'tag:yaml.org,2002:int', + format: 'BIN', + test: /^[-+]?0b[0-1_]+$/, + resolve: (str, _onError, opt) => intResolve(str, 2, 2, opt), + stringify: node => intStringify(node, 2, '0b') +}; +const intOct = { + identify: intIdentify, + default: true, + tag: 'tag:yaml.org,2002:int', + format: 'OCT', + test: /^[-+]?0[0-7_]+$/, + resolve: (str, _onError, opt) => intResolve(str, 1, 8, opt), + stringify: node => intStringify(node, 8, '0') +}; +const int = { + identify: intIdentify, + default: true, + tag: 'tag:yaml.org,2002:int', + test: /^[-+]?[0-9][0-9_]*$/, + resolve: (str, _onError, opt) => intResolve(str, 0, 10, opt), + stringify: stringifyNumber.stringifyNumber +}; +const intHex = { + identify: intIdentify, + default: true, + tag: 'tag:yaml.org,2002:int', + format: 'HEX', + test: /^[-+]?0x[0-9a-fA-F_]+$/, + resolve: (str, _onError, opt) => intResolve(str, 2, 16, opt), + stringify: node => intStringify(node, 16, '0x') +}; + +exports.int = int; +exports.intBin = intBin; +exports.intHex = intHex; +exports.intOct = intOct; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/merge.js b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/merge.js new file mode 100644 index 00000000..7d64b4a8 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/merge.js @@ -0,0 +1,71 @@ +'use strict'; + +var identity = require('../../nodes/identity.js'); +var Scalar = require('../../nodes/Scalar.js'); + +// If the value associated with a merge key is a single mapping node, each of +// its key/value pairs is inserted into the current mapping, unless the key +// already exists in it. If the value associated with the merge key is a +// sequence, then this sequence is expected to contain mapping nodes and each +// of these nodes is merged in turn according to its order in the sequence. +// Keys in mapping nodes earlier in the sequence override keys specified in +// later mapping nodes. -- http://yaml.org/type/merge.html +const MERGE_KEY = '<<'; +const merge = { + identify: value => value === MERGE_KEY || + (typeof value === 'symbol' && value.description === MERGE_KEY), + default: 'key', + tag: 'tag:yaml.org,2002:merge', + test: /^<<$/, + resolve: () => Object.assign(new Scalar.Scalar(Symbol(MERGE_KEY)), { + addToJSMap: addMergeToJSMap + }), + stringify: () => MERGE_KEY +}; +const isMergeKey = (ctx, key) => (merge.identify(key) || + (identity.isScalar(key) && + (!key.type || key.type === Scalar.Scalar.PLAIN) && + merge.identify(key.value))) && + ctx?.doc.schema.tags.some(tag => tag.tag === merge.tag && tag.default); +function addMergeToJSMap(ctx, map, value) { + const source = resolveAliasValue(ctx, value); + if (identity.isSeq(source)) + for (const it of source.items) + mergeValue(ctx, map, it); + else if (Array.isArray(source)) + for (const it of source) + mergeValue(ctx, map, it); + else + mergeValue(ctx, map, source); +} +function mergeValue(ctx, map, value) { + const source = resolveAliasValue(ctx, value); + if (!identity.isMap(source)) + throw new Error('Merge sources must be maps or map aliases'); + const srcMap = source.toJSON(null, ctx, Map); + for (const [key, value] of srcMap) { + if (map instanceof Map) { + if (!map.has(key)) + map.set(key, value); + } + else if (map instanceof Set) { + map.add(key); + } + else if (!Object.prototype.hasOwnProperty.call(map, key)) { + Object.defineProperty(map, key, { + value, + writable: true, + enumerable: true, + configurable: true + }); + } + } + return map; +} +function resolveAliasValue(ctx, value) { + return ctx && identity.isAlias(value) ? value.resolve(ctx.doc, ctx) : value; +} + +exports.addMergeToJSMap = addMergeToJSMap; +exports.isMergeKey = isMergeKey; +exports.merge = merge; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/omap.js b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/omap.js new file mode 100644 index 00000000..3ca141de --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/omap.js @@ -0,0 +1,77 @@ +'use strict'; + +var identity = require('../../nodes/identity.js'); +var toJS = require('../../nodes/toJS.js'); +var YAMLMap = require('../../nodes/YAMLMap.js'); +var YAMLSeq = require('../../nodes/YAMLSeq.js'); +var pairs = require('./pairs.js'); + +class YAMLOMap extends YAMLSeq.YAMLSeq { + constructor() { + super(); + this.add = YAMLMap.YAMLMap.prototype.add.bind(this); + this.delete = YAMLMap.YAMLMap.prototype.delete.bind(this); + this.get = YAMLMap.YAMLMap.prototype.get.bind(this); + this.has = YAMLMap.YAMLMap.prototype.has.bind(this); + this.set = YAMLMap.YAMLMap.prototype.set.bind(this); + this.tag = YAMLOMap.tag; + } + /** + * If `ctx` is given, the return type is actually `Map`, + * but TypeScript won't allow widening the signature of a child method. + */ + toJSON(_, ctx) { + if (!ctx) + return super.toJSON(_); + const map = new Map(); + if (ctx?.onCreate) + ctx.onCreate(map); + for (const pair of this.items) { + let key, value; + if (identity.isPair(pair)) { + key = toJS.toJS(pair.key, '', ctx); + value = toJS.toJS(pair.value, key, ctx); + } + else { + key = toJS.toJS(pair, '', ctx); + } + if (map.has(key)) + throw new Error('Ordered maps must not include duplicate keys'); + map.set(key, value); + } + return map; + } + static from(schema, iterable, ctx) { + const pairs$1 = pairs.createPairs(schema, iterable, ctx); + const omap = new this(); + omap.items = pairs$1.items; + return omap; + } +} +YAMLOMap.tag = 'tag:yaml.org,2002:omap'; +const omap = { + collection: 'seq', + identify: value => value instanceof Map, + nodeClass: YAMLOMap, + default: false, + tag: 'tag:yaml.org,2002:omap', + resolve(seq, onError) { + const pairs$1 = pairs.resolvePairs(seq, onError); + const seenKeys = []; + for (const { key } of pairs$1.items) { + if (identity.isScalar(key)) { + if (seenKeys.includes(key.value)) { + onError(`Ordered maps must not include duplicate keys: ${key.value}`); + } + else { + seenKeys.push(key.value); + } + } + } + return Object.assign(new YAMLOMap(), pairs$1); + }, + createNode: (schema, iterable, ctx) => YAMLOMap.from(schema, iterable, ctx) +}; + +exports.YAMLOMap = YAMLOMap; +exports.omap = omap; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/pairs.js b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/pairs.js new file mode 100644 index 00000000..aa32e0fa --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/pairs.js @@ -0,0 +1,82 @@ +'use strict'; + +var identity = require('../../nodes/identity.js'); +var Pair = require('../../nodes/Pair.js'); +var Scalar = require('../../nodes/Scalar.js'); +var YAMLSeq = require('../../nodes/YAMLSeq.js'); + +function resolvePairs(seq, onError) { + if (identity.isSeq(seq)) { + for (let i = 0; i < seq.items.length; ++i) { + let item = seq.items[i]; + if (identity.isPair(item)) + continue; + else if (identity.isMap(item)) { + if (item.items.length > 1) + onError('Each pair must have its own sequence indicator'); + const pair = item.items[0] || new Pair.Pair(new Scalar.Scalar(null)); + if (item.commentBefore) + pair.key.commentBefore = pair.key.commentBefore + ? `${item.commentBefore}\n${pair.key.commentBefore}` + : item.commentBefore; + if (item.comment) { + const cn = pair.value ?? pair.key; + cn.comment = cn.comment + ? `${item.comment}\n${cn.comment}` + : item.comment; + } + item = pair; + } + seq.items[i] = identity.isPair(item) ? item : new Pair.Pair(item); + } + } + else + onError('Expected a sequence for this tag'); + return seq; +} +function createPairs(schema, iterable, ctx) { + const { replacer } = ctx; + const pairs = new YAMLSeq.YAMLSeq(schema); + pairs.tag = 'tag:yaml.org,2002:pairs'; + let i = 0; + if (iterable && Symbol.iterator in Object(iterable)) + for (let it of iterable) { + if (typeof replacer === 'function') + it = replacer.call(iterable, String(i++), it); + let key, value; + if (Array.isArray(it)) { + if (it.length === 2) { + key = it[0]; + value = it[1]; + } + else + throw new TypeError(`Expected [key, value] tuple: ${it}`); + } + else if (it && it instanceof Object) { + const keys = Object.keys(it); + if (keys.length === 1) { + key = keys[0]; + value = it[key]; + } + else { + throw new TypeError(`Expected tuple with one key, not ${keys.length} keys`); + } + } + else { + key = it; + } + pairs.items.push(Pair.createPair(key, value, ctx)); + } + return pairs; +} +const pairs = { + collection: 'seq', + default: false, + tag: 'tag:yaml.org,2002:pairs', + resolve: resolvePairs, + createNode: createPairs +}; + +exports.createPairs = createPairs; +exports.pairs = pairs; +exports.resolvePairs = resolvePairs; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/schema.js b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/schema.js new file mode 100644 index 00000000..2ea4b730 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/schema.js @@ -0,0 +1,41 @@ +'use strict'; + +var map = require('../common/map.js'); +var _null = require('../common/null.js'); +var seq = require('../common/seq.js'); +var string = require('../common/string.js'); +var binary = require('./binary.js'); +var bool = require('./bool.js'); +var float = require('./float.js'); +var int = require('./int.js'); +var merge = require('./merge.js'); +var omap = require('./omap.js'); +var pairs = require('./pairs.js'); +var set = require('./set.js'); +var timestamp = require('./timestamp.js'); + +const schema = [ + map.map, + seq.seq, + string.string, + _null.nullTag, + bool.trueTag, + bool.falseTag, + int.intBin, + int.intOct, + int.int, + int.intHex, + float.floatNaN, + float.floatExp, + float.float, + binary.binary, + merge.merge, + omap.omap, + pairs.pairs, + set.set, + timestamp.intTime, + timestamp.floatTime, + timestamp.timestamp +]; + +exports.schema = schema; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/set.js b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/set.js new file mode 100644 index 00000000..650c2508 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/set.js @@ -0,0 +1,96 @@ +'use strict'; + +var identity = require('../../nodes/identity.js'); +var Pair = require('../../nodes/Pair.js'); +var YAMLMap = require('../../nodes/YAMLMap.js'); + +class YAMLSet extends YAMLMap.YAMLMap { + constructor(schema) { + super(schema); + this.tag = YAMLSet.tag; + } + add(key) { + let pair; + if (identity.isPair(key)) + pair = key; + else if (key && + typeof key === 'object' && + 'key' in key && + 'value' in key && + key.value === null) + pair = new Pair.Pair(key.key, null); + else + pair = new Pair.Pair(key, null); + const prev = YAMLMap.findPair(this.items, pair.key); + if (!prev) + this.items.push(pair); + } + /** + * If `keepPair` is `true`, returns the Pair matching `key`. + * Otherwise, returns the value of that Pair's key. + */ + get(key, keepPair) { + const pair = YAMLMap.findPair(this.items, key); + return !keepPair && identity.isPair(pair) + ? identity.isScalar(pair.key) + ? pair.key.value + : pair.key + : pair; + } + set(key, value) { + if (typeof value !== 'boolean') + throw new Error(`Expected boolean value for set(key, value) in a YAML set, not ${typeof value}`); + const prev = YAMLMap.findPair(this.items, key); + if (prev && !value) { + this.items.splice(this.items.indexOf(prev), 1); + } + else if (!prev && value) { + this.items.push(new Pair.Pair(key)); + } + } + toJSON(_, ctx) { + return super.toJSON(_, ctx, Set); + } + toString(ctx, onComment, onChompKeep) { + if (!ctx) + return JSON.stringify(this); + if (this.hasAllNullValues(true)) + return super.toString(Object.assign({}, ctx, { allNullValues: true }), onComment, onChompKeep); + else + throw new Error('Set items must all have null values'); + } + static from(schema, iterable, ctx) { + const { replacer } = ctx; + const set = new this(schema); + if (iterable && Symbol.iterator in Object(iterable)) + for (let value of iterable) { + if (typeof replacer === 'function') + value = replacer.call(iterable, value, value); + set.items.push(Pair.createPair(value, null, ctx)); + } + return set; + } +} +YAMLSet.tag = 'tag:yaml.org,2002:set'; +const set = { + collection: 'map', + identify: value => value instanceof Set, + nodeClass: YAMLSet, + default: false, + tag: 'tag:yaml.org,2002:set', + createNode: (schema, iterable, ctx) => YAMLSet.from(schema, iterable, ctx), + resolve(map, onError) { + if (identity.isMap(map)) { + if (map.hasAllNullValues(true)) + return Object.assign(new YAMLSet(), map); + else + onError('Set items must all have null values'); + } + else + onError('Expected a mapping for this tag'); + return map; + } +}; + +exports.YAMLSet = YAMLSet; +exports.set = set; diff --git a/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/timestamp.js b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/timestamp.js new file mode 100644 index 00000000..982fd063 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/schema/yaml-1.1/timestamp.js @@ -0,0 +1,105 @@ +'use strict'; + +var stringifyNumber = require('../../stringify/stringifyNumber.js'); + +/** Internal types handle bigint as number, because TS can't figure it out. */ +function parseSexagesimal(str, asBigInt) { + const sign = str[0]; + const parts = sign === '-' || sign === '+' ? str.substring(1) : str; + const num = (n) => asBigInt ? BigInt(n) : Number(n); + const res = parts + .replace(/_/g, '') + .split(':') + .reduce((res, p) => res * num(60) + num(p), num(0)); + return (sign === '-' ? num(-1) * res : res); +} +/** + * hhhh:mm:ss.sss + * + * Internal types handle bigint as number, because TS can't figure it out. + */ +function stringifySexagesimal(node) { + let { value } = node; + let num = (n) => n; + if (typeof value === 'bigint') + num = n => BigInt(n); + else if (isNaN(value) || !isFinite(value)) + return stringifyNumber.stringifyNumber(node); + let sign = ''; + if (value < 0) { + sign = '-'; + value *= num(-1); + } + const _60 = num(60); + const parts = [value % _60]; // seconds, including ms + if (value < 60) { + parts.unshift(0); // at least one : is required + } + else { + value = (value - parts[0]) / _60; + parts.unshift(value % _60); // minutes + if (value >= 60) { + value = (value - parts[0]) / _60; + parts.unshift(value); // hours + } + } + return (sign + + parts + .map(n => String(n).padStart(2, '0')) + .join(':') + .replace(/000000\d*$/, '') // % 60 may introduce error + ); +} +const intTime = { + identify: value => typeof value === 'bigint' || Number.isInteger(value), + default: true, + tag: 'tag:yaml.org,2002:int', + format: 'TIME', + test: /^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+$/, + resolve: (str, _onError, { intAsBigInt }) => parseSexagesimal(str, intAsBigInt), + stringify: stringifySexagesimal +}; +const floatTime = { + identify: value => typeof value === 'number', + default: true, + tag: 'tag:yaml.org,2002:float', + format: 'TIME', + test: /^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*$/, + resolve: str => parseSexagesimal(str, false), + stringify: stringifySexagesimal +}; +const timestamp = { + identify: value => value instanceof Date, + default: true, + tag: 'tag:yaml.org,2002:timestamp', + // If the time zone is omitted, the timestamp is assumed to be specified in UTC. The time part + // may be omitted altogether, resulting in a date format. In such a case, the time part is + // assumed to be 00:00:00Z (start of day, UTC). + test: RegExp('^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})' + // YYYY-Mm-Dd + '(?:' + // time is optional + '(?:t|T|[ \\t]+)' + // t | T | whitespace + '([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2}(\\.[0-9]+)?)' + // Hh:Mm:Ss(.ss)? + '(?:[ \\t]*(Z|[-+][012]?[0-9](?::[0-9]{2})?))?' + // Z | +5 | -03:30 + ')?$'), + resolve(str) { + const match = str.match(timestamp.test); + if (!match) + throw new Error('!!timestamp expects a date, starting with yyyy-mm-dd'); + const [, year, month, day, hour, minute, second] = match.map(Number); + const millisec = match[7] ? Number((match[7] + '00').substr(1, 3)) : 0; + let date = Date.UTC(year, month - 1, day, hour || 0, minute || 0, second || 0, millisec); + const tz = match[8]; + if (tz && tz !== 'Z') { + let d = parseSexagesimal(tz, false); + if (Math.abs(d) < 30) + d *= 60; + date -= 60000 * d; + } + return new Date(date); + }, + stringify: ({ value }) => value?.toISOString().replace(/(T00:00:00)?\.000Z$/, '') ?? '' +}; + +exports.floatTime = floatTime; +exports.intTime = intTime; +exports.timestamp = timestamp; diff --git a/bundled-addons/pithos/vendor/yaml/lib/stringify/foldFlowLines.js b/bundled-addons/pithos/vendor/yaml/lib/stringify/foldFlowLines.js new file mode 100644 index 00000000..9c610589 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/stringify/foldFlowLines.js @@ -0,0 +1,151 @@ +'use strict'; + +const FOLD_FLOW = 'flow'; +const FOLD_BLOCK = 'block'; +const FOLD_QUOTED = 'quoted'; +/** + * Tries to keep input at up to `lineWidth` characters, splitting only on spaces + * not followed by newlines or spaces unless `mode` is `'quoted'`. Lines are + * terminated with `\n` and started with `indent`. + */ +function foldFlowLines(text, indent, mode = 'flow', { indentAtStart, lineWidth = 80, minContentWidth = 20, onFold, onOverflow } = {}) { + if (!lineWidth || lineWidth < 0) + return text; + if (lineWidth < minContentWidth) + minContentWidth = 0; + const endStep = Math.max(1 + minContentWidth, 1 + lineWidth - indent.length); + if (text.length <= endStep) + return text; + const folds = []; + const escapedFolds = {}; + let end = lineWidth - indent.length; + if (typeof indentAtStart === 'number') { + if (indentAtStart > lineWidth - Math.max(2, minContentWidth)) + folds.push(0); + else + end = lineWidth - indentAtStart; + } + let split = undefined; + let prev = undefined; + let overflow = false; + let i = -1; + let escStart = -1; + let escEnd = -1; + if (mode === FOLD_BLOCK) { + i = consumeMoreIndentedLines(text, i, indent.length); + if (i !== -1) + end = i + endStep; + } + for (let ch; (ch = text[(i += 1)]);) { + if (mode === FOLD_QUOTED && ch === '\\') { + escStart = i; + switch (text[i + 1]) { + case 'x': + i += 3; + break; + case 'u': + i += 5; + break; + case 'U': + i += 9; + break; + default: + i += 1; + } + escEnd = i; + } + if (ch === '\n') { + if (mode === FOLD_BLOCK) + i = consumeMoreIndentedLines(text, i, indent.length); + end = i + indent.length + endStep; + split = undefined; + } + else { + if (ch === ' ' && + prev && + prev !== ' ' && + prev !== '\n' && + prev !== '\t') { + // space surrounded by non-space can be replaced with newline + indent + const next = text[i + 1]; + if (next && next !== ' ' && next !== '\n' && next !== '\t') + split = i; + } + if (i >= end) { + if (split) { + folds.push(split); + end = split + endStep; + split = undefined; + } + else if (mode === FOLD_QUOTED) { + // white-space collected at end may stretch past lineWidth + while (prev === ' ' || prev === '\t') { + prev = ch; + ch = text[(i += 1)]; + overflow = true; + } + // Account for newline escape, but don't break preceding escape + const j = i > escEnd + 1 ? i - 2 : escStart - 1; + // Bail out if lineWidth & minContentWidth are shorter than an escape string + if (escapedFolds[j]) + return text; + folds.push(j); + escapedFolds[j] = true; + end = j + endStep; + split = undefined; + } + else { + overflow = true; + } + } + } + prev = ch; + } + if (overflow && onOverflow) + onOverflow(); + if (folds.length === 0) + return text; + if (onFold) + onFold(); + let res = text.slice(0, folds[0]); + for (let i = 0; i < folds.length; ++i) { + const fold = folds[i]; + const end = folds[i + 1] || text.length; + if (fold === 0) + res = `\n${indent}${text.slice(0, end)}`; + else { + if (mode === FOLD_QUOTED && escapedFolds[fold]) + res += `${text[fold]}\\`; + res += `\n${indent}${text.slice(fold + 1, end)}`; + } + } + return res; +} +/** + * Presumes `i + 1` is at the start of a line + * @returns index of last newline in more-indented block + */ +function consumeMoreIndentedLines(text, i, indent) { + let end = i; + let start = i + 1; + let ch = text[start]; + while (ch === ' ' || ch === '\t') { + if (i < start + indent) { + ch = text[++i]; + } + else { + do { + ch = text[++i]; + } while (ch && ch !== '\n'); + end = i; + start = i + 1; + ch = text[start]; + } + } + return end; +} + +exports.FOLD_BLOCK = FOLD_BLOCK; +exports.FOLD_FLOW = FOLD_FLOW; +exports.FOLD_QUOTED = FOLD_QUOTED; +exports.foldFlowLines = foldFlowLines; diff --git a/bundled-addons/pithos/vendor/yaml/lib/stringify/stringify.js b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringify.js new file mode 100644 index 00000000..d837ec75 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringify.js @@ -0,0 +1,132 @@ +'use strict'; + +var anchors = require('../doc/anchors.js'); +var identity = require('../nodes/identity.js'); +var stringifyComment = require('./stringifyComment.js'); +var stringifyString = require('./stringifyString.js'); + +function createStringifyContext(doc, options) { + const opt = Object.assign({ + blockQuote: true, + commentString: stringifyComment.stringifyComment, + defaultKeyType: null, + defaultStringType: 'PLAIN', + directives: null, + doubleQuotedAsJSON: false, + doubleQuotedMinMultiLineLength: 40, + falseStr: 'false', + flowCollectionPadding: true, + indentSeq: true, + lineWidth: 80, + minContentWidth: 20, + nullStr: 'null', + simpleKeys: false, + singleQuote: null, + trailingComma: false, + trueStr: 'true', + verifyAliasOrder: true + }, doc.schema.toStringOptions, options); + let inFlow; + switch (opt.collectionStyle) { + case 'block': + inFlow = false; + break; + case 'flow': + inFlow = true; + break; + default: + inFlow = null; + } + return { + anchors: new Set(), + doc, + flowCollectionPadding: opt.flowCollectionPadding ? ' ' : '', + indent: '', + indentStep: typeof opt.indent === 'number' ? ' '.repeat(opt.indent) : ' ', + inFlow, + options: opt + }; +} +function getTagObject(tags, item) { + if (item.tag) { + const match = tags.filter(t => t.tag === item.tag); + if (match.length > 0) + return match.find(t => t.format === item.format) ?? match[0]; + } + let tagObj = undefined; + let obj; + if (identity.isScalar(item)) { + obj = item.value; + let match = tags.filter(t => t.identify?.(obj)); + if (match.length > 1) { + const testMatch = match.filter(t => t.test); + if (testMatch.length > 0) + match = testMatch; + } + tagObj = + match.find(t => t.format === item.format) ?? match.find(t => !t.format); + } + else { + obj = item; + tagObj = tags.find(t => t.nodeClass && obj instanceof t.nodeClass); + } + if (!tagObj) { + const name = obj?.constructor?.name ?? (obj === null ? 'null' : typeof obj); + throw new Error(`Tag not resolved for ${name} value`); + } + return tagObj; +} +// needs to be called before value stringifier to allow for circular anchor refs +function stringifyProps(node, tagObj, { anchors: anchors$1, doc }) { + if (!doc.directives) + return ''; + const props = []; + const anchor = (identity.isScalar(node) || identity.isCollection(node)) && node.anchor; + if (anchor && anchors.anchorIsValid(anchor)) { + anchors$1.add(anchor); + props.push(`&${anchor}`); + } + const tag = node.tag ?? (tagObj.default ? null : tagObj.tag); + if (tag) + props.push(doc.directives.tagString(tag)); + return props.join(' '); +} +function stringify(item, ctx, onComment, onChompKeep) { + if (identity.isPair(item)) + return item.toString(ctx, onComment, onChompKeep); + if (identity.isAlias(item)) { + if (ctx.doc.directives) + return item.toString(ctx); + if (ctx.resolvedAliases?.has(item)) { + throw new TypeError(`Cannot stringify circular structure without alias nodes`); + } + else { + if (ctx.resolvedAliases) + ctx.resolvedAliases.add(item); + else + ctx.resolvedAliases = new Set([item]); + item = item.resolve(ctx.doc); + } + } + let tagObj = undefined; + const node = identity.isNode(item) + ? item + : ctx.doc.createNode(item, { onTagObj: o => (tagObj = o) }); + tagObj ?? (tagObj = getTagObject(ctx.doc.schema.tags, node)); + const props = stringifyProps(node, tagObj, ctx); + if (props.length > 0) + ctx.indentAtStart = (ctx.indentAtStart ?? 0) + props.length + 1; + const str = typeof tagObj.stringify === 'function' + ? tagObj.stringify(node, ctx, onComment, onChompKeep) + : identity.isScalar(node) + ? stringifyString.stringifyString(node, ctx, onComment, onChompKeep) + : node.toString(ctx, onComment, onChompKeep); + if (!props) + return str; + return identity.isScalar(node) || str[0] === '{' || str[0] === '[' + ? `${props} ${str}` + : `${props}\n${ctx.indent}${str}`; +} + +exports.createStringifyContext = createStringifyContext; +exports.stringify = stringify; diff --git a/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyCollection.js b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyCollection.js new file mode 100644 index 00000000..6f7839ef --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyCollection.js @@ -0,0 +1,155 @@ +'use strict'; + +var identity = require('../nodes/identity.js'); +var stringify = require('./stringify.js'); +var stringifyComment = require('./stringifyComment.js'); + +function stringifyCollection(collection, ctx, options) { + const flow = ctx.inFlow ?? collection.flow; + const stringify = flow ? stringifyFlowCollection : stringifyBlockCollection; + return stringify(collection, ctx, options); +} +function stringifyBlockCollection({ comment, items }, ctx, { blockItemPrefix, flowChars, itemIndent, onChompKeep, onComment }) { + const { indent, options: { commentString } } = ctx; + const itemCtx = Object.assign({}, ctx, { indent: itemIndent, type: null }); + let chompKeep = false; // flag for the preceding node's status + const lines = []; + for (let i = 0; i < items.length; ++i) { + const item = items[i]; + let comment = null; + if (identity.isNode(item)) { + if (!chompKeep && item.spaceBefore) + lines.push(''); + addCommentBefore(ctx, lines, item.commentBefore, chompKeep); + if (item.comment) + comment = item.comment; + } + else if (identity.isPair(item)) { + const ik = identity.isNode(item.key) ? item.key : null; + if (ik) { + if (!chompKeep && ik.spaceBefore) + lines.push(''); + addCommentBefore(ctx, lines, ik.commentBefore, chompKeep); + } + } + chompKeep = false; + let str = stringify.stringify(item, itemCtx, () => (comment = null), () => (chompKeep = true)); + if (comment) + str += stringifyComment.lineComment(str, itemIndent, commentString(comment)); + if (chompKeep && comment) + chompKeep = false; + lines.push(blockItemPrefix + str); + } + let str; + if (lines.length === 0) { + str = flowChars.start + flowChars.end; + } + else { + str = lines[0]; + for (let i = 1; i < lines.length; ++i) { + const line = lines[i]; + str += line ? `\n${indent}${line}` : '\n'; + } + } + if (comment) { + str += '\n' + stringifyComment.indentComment(commentString(comment), indent); + if (onComment) + onComment(); + } + else if (chompKeep && onChompKeep) + onChompKeep(); + return str; +} +function stringifyFlowCollection({ items }, ctx, { flowChars, itemIndent }) { + const { indent, indentStep, flowCollectionPadding: fcPadding, options: { commentString } } = ctx; + itemIndent += indentStep; + const itemCtx = Object.assign({}, ctx, { + indent: itemIndent, + inFlow: true, + type: null + }); + let reqNewline = false; + let linesAtValue = 0; + const lines = []; + for (let i = 0; i < items.length; ++i) { + const item = items[i]; + let comment = null; + if (identity.isNode(item)) { + if (item.spaceBefore) + lines.push(''); + addCommentBefore(ctx, lines, item.commentBefore, false); + if (item.comment) + comment = item.comment; + } + else if (identity.isPair(item)) { + const ik = identity.isNode(item.key) ? item.key : null; + if (ik) { + if (ik.spaceBefore) + lines.push(''); + addCommentBefore(ctx, lines, ik.commentBefore, false); + if (ik.comment) + reqNewline = true; + } + const iv = identity.isNode(item.value) ? item.value : null; + if (iv) { + if (iv.comment) + comment = iv.comment; + if (iv.commentBefore) + reqNewline = true; + } + else if (item.value == null && ik?.comment) { + comment = ik.comment; + } + } + if (comment) + reqNewline = true; + let str = stringify.stringify(item, itemCtx, () => (comment = null)); + reqNewline || (reqNewline = lines.length > linesAtValue || str.includes('\n')); + if (i < items.length - 1) { + str += ','; + } + else if (ctx.options.trailingComma) { + if (ctx.options.lineWidth > 0) { + reqNewline || (reqNewline = lines.reduce((sum, line) => sum + line.length + 2, 2) + + (str.length + 2) > + ctx.options.lineWidth); + } + if (reqNewline) { + str += ','; + } + } + if (comment) + str += stringifyComment.lineComment(str, itemIndent, commentString(comment)); + lines.push(str); + linesAtValue = lines.length; + } + const { start, end } = flowChars; + if (lines.length === 0) { + return start + end; + } + else { + if (!reqNewline) { + const len = lines.reduce((sum, line) => sum + line.length + 2, 2); + reqNewline = ctx.options.lineWidth > 0 && len > ctx.options.lineWidth; + } + if (reqNewline) { + let str = start; + for (const line of lines) + str += line ? `\n${indentStep}${indent}${line}` : '\n'; + return `${str}\n${indent}${end}`; + } + else { + return `${start}${fcPadding}${lines.join(' ')}${fcPadding}${end}`; + } + } +} +function addCommentBefore({ indent, options: { commentString } }, lines, comment, chompKeep) { + if (comment && chompKeep) + comment = comment.replace(/^\n+/, ''); + if (comment) { + const ic = stringifyComment.indentComment(commentString(comment), indent); + lines.push(ic.trimStart()); // Avoid double indent on first line + } +} + +exports.stringifyCollection = stringifyCollection; diff --git a/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyComment.js b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyComment.js new file mode 100644 index 00000000..26bf361a --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyComment.js @@ -0,0 +1,24 @@ +'use strict'; + +/** + * Stringifies a comment. + * + * Empty comment lines are left empty, + * lines consisting of a single space are replaced by `#`, + * and all other lines are prefixed with a `#`. + */ +const stringifyComment = (str) => str.replace(/^(?!$)(?: $)?/gm, '#'); +function indentComment(comment, indent) { + if (/^\n+$/.test(comment)) + return comment.substring(1); + return indent ? comment.replace(/^(?! *$)/gm, indent) : comment; +} +const lineComment = (str, indent, comment) => str.endsWith('\n') + ? indentComment(comment, indent) + : comment.includes('\n') + ? '\n' + indentComment(comment, indent) + : (str.endsWith(' ') ? '' : ' ') + comment; + +exports.indentComment = indentComment; +exports.lineComment = lineComment; +exports.stringifyComment = stringifyComment; diff --git a/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyDocument.js b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyDocument.js new file mode 100644 index 00000000..fb9d73cb --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyDocument.js @@ -0,0 +1,87 @@ +'use strict'; + +var identity = require('../nodes/identity.js'); +var stringify = require('./stringify.js'); +var stringifyComment = require('./stringifyComment.js'); + +function stringifyDocument(doc, options) { + const lines = []; + let hasDirectives = options.directives === true; + if (options.directives !== false && doc.directives) { + const dir = doc.directives.toString(doc); + if (dir) { + lines.push(dir); + hasDirectives = true; + } + else if (doc.directives.docStart) + hasDirectives = true; + } + if (hasDirectives) + lines.push('---'); + const ctx = stringify.createStringifyContext(doc, options); + const { commentString } = ctx.options; + if (doc.commentBefore) { + if (lines.length !== 1) + lines.unshift(''); + const cs = commentString(doc.commentBefore); + lines.unshift(stringifyComment.indentComment(cs, '')); + } + let chompKeep = false; + let contentComment = null; + if (doc.contents) { + if (identity.isNode(doc.contents)) { + if (doc.contents.spaceBefore && hasDirectives) + lines.push(''); + if (doc.contents.commentBefore) { + const cs = commentString(doc.contents.commentBefore); + lines.push(stringifyComment.indentComment(cs, '')); + } + // top-level block scalars need to be indented if followed by a comment + ctx.forceBlockIndent = !!doc.comment; + contentComment = doc.contents.comment; + } + const onChompKeep = contentComment ? undefined : () => (chompKeep = true); + let body = stringify.stringify(doc.contents, ctx, () => (contentComment = null), onChompKeep); + if (contentComment) + body += stringifyComment.lineComment(body, '', commentString(contentComment)); + if ((body[0] === '|' || body[0] === '>') && + lines[lines.length - 1] === '---') { + // Top-level block scalars with a preceding doc marker ought to use the + // same line for their header. + lines[lines.length - 1] = `--- ${body}`; + } + else + lines.push(body); + } + else { + lines.push(stringify.stringify(doc.contents, ctx)); + } + if (doc.directives?.docEnd) { + if (doc.comment) { + const cs = commentString(doc.comment); + if (cs.includes('\n')) { + lines.push('...'); + lines.push(stringifyComment.indentComment(cs, '')); + } + else { + lines.push(`... ${cs}`); + } + } + else { + lines.push('...'); + } + } + else { + let dc = doc.comment; + if (dc && chompKeep) + dc = dc.replace(/^\n+/, ''); + if (dc) { + if ((!chompKeep || contentComment) && lines[lines.length - 1] !== '') + lines.push(''); + lines.push(stringifyComment.indentComment(commentString(dc), '')); + } + } + return lines.join('\n') + '\n'; +} + +exports.stringifyDocument = stringifyDocument; diff --git a/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyNumber.js b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyNumber.js new file mode 100644 index 00000000..100972a6 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyNumber.js @@ -0,0 +1,27 @@ +'use strict'; + +function stringifyNumber({ format, minFractionDigits, tag, value }) { + if (typeof value === 'bigint') + return String(value); + const num = typeof value === 'number' ? value : Number(value); + if (!isFinite(num)) + return isNaN(num) ? '.nan' : num < 0 ? '-.inf' : '.inf'; + let n = Object.is(value, -0) ? '-0' : JSON.stringify(value); + if (!format && + minFractionDigits && + (!tag || tag === 'tag:yaml.org,2002:float') && + /^-?\d/.test(n) && + !n.includes('e')) { + let i = n.indexOf('.'); + if (i < 0) { + i = n.length; + n += '.'; + } + let d = minFractionDigits - (n.length - i - 1); + while (d-- > 0) + n += '0'; + } + return n; +} + +exports.stringifyNumber = stringifyNumber; diff --git a/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyPair.js b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyPair.js new file mode 100644 index 00000000..25f974b2 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyPair.js @@ -0,0 +1,152 @@ +'use strict'; + +var identity = require('../nodes/identity.js'); +var Scalar = require('../nodes/Scalar.js'); +var stringify = require('./stringify.js'); +var stringifyComment = require('./stringifyComment.js'); + +function stringifyPair({ key, value }, ctx, onComment, onChompKeep) { + const { allNullValues, doc, indent, indentStep, options: { commentString, indentSeq, simpleKeys } } = ctx; + let keyComment = (identity.isNode(key) && key.comment) || null; + if (simpleKeys) { + if (keyComment) { + throw new Error('With simple keys, key nodes cannot have comments'); + } + if (identity.isCollection(key) || (!identity.isNode(key) && typeof key === 'object')) { + const msg = 'With simple keys, collection cannot be used as a key value'; + throw new Error(msg); + } + } + let explicitKey = !simpleKeys && + (!key || + (keyComment && value == null && !ctx.inFlow) || + identity.isCollection(key) || + (identity.isScalar(key) + ? key.type === Scalar.Scalar.BLOCK_FOLDED || key.type === Scalar.Scalar.BLOCK_LITERAL + : typeof key === 'object')); + ctx = Object.assign({}, ctx, { + allNullValues: false, + implicitKey: !explicitKey && (simpleKeys || !allNullValues), + indent: indent + indentStep + }); + let keyCommentDone = false; + let chompKeep = false; + let str = stringify.stringify(key, ctx, () => (keyCommentDone = true), () => (chompKeep = true)); + if (!explicitKey && !ctx.inFlow && str.length > 1024) { + if (simpleKeys) + throw new Error('With simple keys, single line scalar must not span more than 1024 characters'); + explicitKey = true; + } + if (ctx.inFlow) { + if (allNullValues || value == null) { + if (keyCommentDone && onComment) + onComment(); + return str === '' ? '?' : explicitKey ? `? ${str}` : str; + } + } + else if ((allNullValues && !simpleKeys) || (value == null && explicitKey)) { + str = `? ${str}`; + if (keyComment && !keyCommentDone) { + str += stringifyComment.lineComment(str, ctx.indent, commentString(keyComment)); + } + else if (chompKeep && onChompKeep) + onChompKeep(); + return str; + } + if (keyCommentDone) + keyComment = null; + if (explicitKey) { + if (keyComment) + str += stringifyComment.lineComment(str, ctx.indent, commentString(keyComment)); + str = `? ${str}\n${indent}:`; + } + else { + str = `${str}:`; + if (keyComment) + str += stringifyComment.lineComment(str, ctx.indent, commentString(keyComment)); + } + let vsb, vcb, valueComment; + if (identity.isNode(value)) { + vsb = !!value.spaceBefore; + vcb = value.commentBefore; + valueComment = value.comment; + } + else { + vsb = false; + vcb = null; + valueComment = null; + if (value && typeof value === 'object') + value = doc.createNode(value); + } + ctx.implicitKey = false; + if (!explicitKey && !keyComment && identity.isScalar(value)) + ctx.indentAtStart = str.length + 1; + chompKeep = false; + if (!indentSeq && + indentStep.length >= 2 && + !ctx.inFlow && + !explicitKey && + identity.isSeq(value) && + !value.flow && + !value.tag && + !value.anchor) { + // If indentSeq === false, consider '- ' as part of indentation where possible + ctx.indent = ctx.indent.substring(2); + } + let valueCommentDone = false; + const valueStr = stringify.stringify(value, ctx, () => (valueCommentDone = true), () => (chompKeep = true)); + let ws = ' '; + if (keyComment || vsb || vcb) { + ws = vsb ? '\n' : ''; + if (vcb) { + const cs = commentString(vcb); + ws += `\n${stringifyComment.indentComment(cs, ctx.indent)}`; + } + if (valueStr === '' && !ctx.inFlow) { + if (ws === '\n' && valueComment) + ws = '\n\n'; + } + else { + ws += `\n${ctx.indent}`; + } + } + else if (!explicitKey && identity.isCollection(value)) { + const vs0 = valueStr[0]; + const nl0 = valueStr.indexOf('\n'); + const hasNewline = nl0 !== -1; + const flow = ctx.inFlow ?? value.flow ?? value.items.length === 0; + if (hasNewline || !flow) { + let hasPropsLine = false; + if (hasNewline && (vs0 === '&' || vs0 === '!')) { + let sp0 = valueStr.indexOf(' '); + if (vs0 === '&' && + sp0 !== -1 && + sp0 < nl0 && + valueStr[sp0 + 1] === '!') { + sp0 = valueStr.indexOf(' ', sp0 + 1); + } + if (sp0 === -1 || nl0 < sp0) + hasPropsLine = true; + } + if (!hasPropsLine) + ws = `\n${ctx.indent}`; + } + } + else if (valueStr === '' || valueStr[0] === '\n') { + ws = ''; + } + str += ws + valueStr; + if (ctx.inFlow) { + if (valueCommentDone && onComment) + onComment(); + } + else if (valueComment && !valueCommentDone) { + str += stringifyComment.lineComment(str, ctx.indent, commentString(valueComment)); + } + else if (chompKeep && onChompKeep) { + onChompKeep(); + } + return str; +} + +exports.stringifyPair = stringifyPair; diff --git a/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyString.js b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyString.js new file mode 100644 index 00000000..2caadc63 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/stringify/stringifyString.js @@ -0,0 +1,338 @@ +'use strict'; + +var Scalar = require('../nodes/Scalar.js'); +var foldFlowLines = require('./foldFlowLines.js'); + +const getFoldOptions = (ctx, isBlock) => ({ + indentAtStart: isBlock ? ctx.indent.length : ctx.indentAtStart, + lineWidth: ctx.options.lineWidth, + minContentWidth: ctx.options.minContentWidth +}); +// Also checks for lines starting with %, as parsing the output as YAML 1.1 will +// presume that's starting a new document. +const containsDocumentMarker = (str) => /^(%|---|\.\.\.)/m.test(str); +function lineLengthOverLimit(str, lineWidth, indentLength) { + if (!lineWidth || lineWidth < 0) + return false; + const limit = lineWidth - indentLength; + const strLen = str.length; + if (strLen <= limit) + return false; + for (let i = 0, start = 0; i < strLen; ++i) { + if (str[i] === '\n') { + if (i - start > limit) + return true; + start = i + 1; + if (strLen - start <= limit) + return false; + } + } + return true; +} +function doubleQuotedString(value, ctx) { + const json = JSON.stringify(value); + if (ctx.options.doubleQuotedAsJSON) + return json; + const { implicitKey } = ctx; + const minMultiLineLength = ctx.options.doubleQuotedMinMultiLineLength; + const indent = ctx.indent || (containsDocumentMarker(value) ? ' ' : ''); + let str = ''; + let start = 0; + for (let i = 0, ch = json[i]; ch; ch = json[++i]) { + if (ch === ' ' && json[i + 1] === '\\' && json[i + 2] === 'n') { + // space before newline needs to be escaped to not be folded + str += json.slice(start, i) + '\\ '; + i += 1; + start = i; + ch = '\\'; + } + if (ch === '\\') + switch (json[i + 1]) { + case 'u': + { + str += json.slice(start, i); + const code = json.substr(i + 2, 4); + switch (code) { + case '0000': + str += '\\0'; + break; + case '0007': + str += '\\a'; + break; + case '000b': + str += '\\v'; + break; + case '001b': + str += '\\e'; + break; + case '0085': + str += '\\N'; + break; + case '00a0': + str += '\\_'; + break; + case '2028': + str += '\\L'; + break; + case '2029': + str += '\\P'; + break; + default: + if (code.substr(0, 2) === '00') + str += '\\x' + code.substr(2); + else + str += json.substr(i, 6); + } + i += 5; + start = i + 1; + } + break; + case 'n': + if (implicitKey || + json[i + 2] === '"' || + json.length < minMultiLineLength) { + i += 1; + } + else { + // folding will eat first newline + str += json.slice(start, i) + '\n\n'; + while (json[i + 2] === '\\' && + json[i + 3] === 'n' && + json[i + 4] !== '"') { + str += '\n'; + i += 2; + } + str += indent; + // space after newline needs to be escaped to not be folded + if (json[i + 2] === ' ') + str += '\\'; + i += 1; + start = i + 1; + } + break; + default: + i += 1; + } + } + str = start ? str + json.slice(start) : json; + return implicitKey + ? str + : foldFlowLines.foldFlowLines(str, indent, foldFlowLines.FOLD_QUOTED, getFoldOptions(ctx, false)); +} +function singleQuotedString(value, ctx) { + if (ctx.options.singleQuote === false || + (ctx.implicitKey && value.includes('\n')) || + /[ \t]\n|\n[ \t]/.test(value) // single quoted string can't have leading or trailing whitespace around newline + ) + return doubleQuotedString(value, ctx); + const indent = ctx.indent || (containsDocumentMarker(value) ? ' ' : ''); + const res = "'" + value.replace(/'/g, "''").replace(/\n+/g, `$&\n${indent}`) + "'"; + return ctx.implicitKey + ? res + : foldFlowLines.foldFlowLines(res, indent, foldFlowLines.FOLD_FLOW, getFoldOptions(ctx, false)); +} +function quotedString(value, ctx) { + const { singleQuote } = ctx.options; + let qs; + if (singleQuote === false) + qs = doubleQuotedString; + else { + const hasDouble = value.includes('"'); + const hasSingle = value.includes("'"); + if (hasDouble && !hasSingle) + qs = singleQuotedString; + else if (hasSingle && !hasDouble) + qs = doubleQuotedString; + else + qs = singleQuote ? singleQuotedString : doubleQuotedString; + } + return qs(value, ctx); +} +// The negative lookbehind avoids a polynomial search, +// but isn't supported yet on Safari: https://caniuse.com/js-regexp-lookbehind +let blockEndNewlines; +try { + blockEndNewlines = new RegExp('(^|(?\n'; + // determine chomping from whitespace at value end + let chomp; + let endStart; + for (endStart = value.length; endStart > 0; --endStart) { + const ch = value[endStart - 1]; + if (ch !== '\n' && ch !== '\t' && ch !== ' ') + break; + } + let end = value.substring(endStart); + const endNlPos = end.indexOf('\n'); + if (endNlPos === -1) { + chomp = '-'; // strip + } + else if (value === end || endNlPos !== end.length - 1) { + chomp = '+'; // keep + if (onChompKeep) + onChompKeep(); + } + else { + chomp = ''; // clip + } + if (end) { + value = value.slice(0, -end.length); + if (end[end.length - 1] === '\n') + end = end.slice(0, -1); + end = end.replace(blockEndNewlines, `$&${indent}`); + } + // determine indent indicator from whitespace at value start + let startWithSpace = false; + let startEnd; + let startNlPos = -1; + for (startEnd = 0; startEnd < value.length; ++startEnd) { + const ch = value[startEnd]; + if (ch === ' ') + startWithSpace = true; + else if (ch === '\n') + startNlPos = startEnd; + else + break; + } + let start = value.substring(0, startNlPos < startEnd ? startNlPos + 1 : startEnd); + if (start) { + value = value.substring(start.length); + start = start.replace(/\n+/g, `$&${indent}`); + } + const indentSize = indent ? '2' : '1'; // root is at -1 + // Leading | or > is added later + let header = (startWithSpace ? indentSize : '') + chomp; + if (comment) { + header += ' ' + commentString(comment.replace(/ ?[\r\n]+/g, ' ')); + if (onComment) + onComment(); + } + if (!literal) { + const foldedValue = value + .replace(/\n+/g, '\n$&') + .replace(/(?:^|\n)([\t ].*)(?:([\n\t ]*)\n(?![\n\t ]))?/g, '$1$2') // more-indented lines aren't folded + // ^ more-ind. ^ empty ^ capture next empty lines only at end of indent + .replace(/\n+/g, `$&${indent}`); + let literalFallback = false; + const foldOptions = getFoldOptions(ctx, true); + if (blockQuote !== 'folded' && type !== Scalar.Scalar.BLOCK_FOLDED) { + foldOptions.onOverflow = () => { + literalFallback = true; + }; + } + const body = foldFlowLines.foldFlowLines(`${start}${foldedValue}${end}`, indent, foldFlowLines.FOLD_BLOCK, foldOptions); + if (!literalFallback) + return `>${header}\n${indent}${body}`; + } + value = value.replace(/\n+/g, `$&${indent}`); + return `|${header}\n${indent}${start}${value}${end}`; +} +function plainString(item, ctx, onComment, onChompKeep) { + const { type, value } = item; + const { actualString, implicitKey, indent, indentStep, inFlow } = ctx; + if ((implicitKey && value.includes('\n')) || + (inFlow && /[[\]{},]/.test(value))) { + return quotedString(value, ctx); + } + if (/^[\n\t ,[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$/.test(value)) { + // not allowed: + // - '-' or '?' + // - start with an indicator character (except [?:-]) or /[?-] / + // - '\n ', ': ' or ' \n' anywhere + // - '#' not preceded by a non-space char + // - end with ' ' or ':' + return implicitKey || inFlow || !value.includes('\n') + ? quotedString(value, ctx) + : blockString(item, ctx, onComment, onChompKeep); + } + if (!implicitKey && + !inFlow && + type !== Scalar.Scalar.PLAIN && + value.includes('\n')) { + // Where allowed & type not set explicitly, prefer block style for multiline strings + return blockString(item, ctx, onComment, onChompKeep); + } + if (containsDocumentMarker(value)) { + if (indent === '') { + ctx.forceBlockIndent = true; + return blockString(item, ctx, onComment, onChompKeep); + } + else if (implicitKey && indent === indentStep) { + return quotedString(value, ctx); + } + } + const str = value.replace(/\n+/g, `$&\n${indent}`); + // Verify that output will be parsed as a string, as e.g. plain numbers and + // booleans get parsed with those types in v1.2 (e.g. '42', 'true' & '0.9e-3'), + // and others in v1.1. + if (actualString) { + const test = (tag) => tag.default && tag.tag !== 'tag:yaml.org,2002:str' && tag.test?.test(str); + const { compat, tags } = ctx.doc.schema; + if (tags.some(test) || compat?.some(test)) + return quotedString(value, ctx); + } + return implicitKey + ? str + : foldFlowLines.foldFlowLines(str, indent, foldFlowLines.FOLD_FLOW, getFoldOptions(ctx, false)); +} +function stringifyString(item, ctx, onComment, onChompKeep) { + const { implicitKey, inFlow } = ctx; + const ss = typeof item.value === 'string' + ? item + : Object.assign({}, item, { value: String(item.value) }); + let { type } = item; + if (type !== Scalar.Scalar.QUOTE_DOUBLE) { + // force double quotes on control characters & unpaired surrogates + if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u{D800}-\u{DFFF}]/u.test(ss.value)) + type = Scalar.Scalar.QUOTE_DOUBLE; + } + const _stringify = (_type) => { + switch (_type) { + case Scalar.Scalar.BLOCK_FOLDED: + case Scalar.Scalar.BLOCK_LITERAL: + return implicitKey || inFlow + ? quotedString(ss.value, ctx) // blocks are not valid inside flow containers + : blockString(ss, ctx, onComment, onChompKeep); + case Scalar.Scalar.QUOTE_DOUBLE: + return doubleQuotedString(ss.value, ctx); + case Scalar.Scalar.QUOTE_SINGLE: + return singleQuotedString(ss.value, ctx); + case Scalar.Scalar.PLAIN: + return plainString(ss, ctx, onComment, onChompKeep); + default: + return null; + } + }; + let res = _stringify(type); + if (res === null) { + const { defaultKeyType, defaultStringType } = ctx.options; + const t = (implicitKey && defaultKeyType) || defaultStringType; + res = _stringify(t); + if (res === null) + throw new Error(`Unsupported default string type ${t}`); + } + return res; +} + +exports.stringifyString = stringifyString; diff --git a/bundled-addons/pithos/vendor/yaml/lib/test-events.js b/bundled-addons/pithos/vendor/yaml/lib/test-events.js new file mode 100644 index 00000000..f38d3365 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/test-events.js @@ -0,0 +1,134 @@ +'use strict'; + +var identity = require('./nodes/identity.js'); +var publicApi = require('./public-api.js'); +var visit = require('./visit.js'); + +const scalarChar = { + BLOCK_FOLDED: '>', + BLOCK_LITERAL: '|', + PLAIN: ':', + QUOTE_DOUBLE: '"', + QUOTE_SINGLE: "'" +}; +function anchorExists(doc, anchor) { + let found = false; + visit.visit(doc, { + Value(_key, node) { + if (node.anchor === anchor) { + found = true; + return visit.visit.BREAK; + } + } + }); + return found; +} +// test harness for yaml-test-suite event tests +function testEvents(src) { + const docs = publicApi.parseAllDocuments(src); + const errDoc = docs.find(doc => doc.errors.length > 0); + const error = errDoc ? errDoc.errors[0].message : null; + const events = ['+STR']; + try { + for (let i = 0; i < docs.length; ++i) { + const doc = docs[i]; + let root = doc.contents; + if (Array.isArray(root)) + root = root[0]; + const [rootStart] = doc.range || [0]; + const error = doc.errors[0]; + if (error && (!error.pos || error.pos[0] < rootStart)) + throw new Error(); + let docStart = '+DOC'; + if (doc.directives.docStart) + docStart += ' ---'; + else if (doc.contents && + doc.contents.range[2] === doc.contents.range[0] && + !doc.contents.anchor && + !doc.contents.tag) + continue; + events.push(docStart); + addEvents(events, doc, error?.pos[0] ?? -1, root); + let docEnd = '-DOC'; + if (doc.directives.docEnd) + docEnd += ' ...'; + events.push(docEnd); + } + } + catch (e) { + return { events, error: error ?? e }; + } + events.push('-STR'); + return { events, error }; +} +function addEvents(events, doc, errPos, node) { + if (!node) { + events.push('=VAL :'); + return; + } + if (errPos !== -1 && identity.isNode(node) && node.range[0] >= errPos) + throw new Error(); + let props = ''; + let anchor = identity.isScalar(node) || identity.isCollection(node) ? node.anchor : undefined; + if (anchor) { + if (/\d$/.test(anchor)) { + const alt = anchor.replace(/\d$/, ''); + if (anchorExists(doc, alt)) + anchor = alt; + } + props = ` &${anchor}`; + } + if (identity.isNode(node) && node.tag) + props += ` <${node.tag}>`; + if (identity.isMap(node)) { + const ev = node.flow ? '+MAP {}' : '+MAP'; + events.push(`${ev}${props}`); + node.items.forEach(({ key, value }) => { + addEvents(events, doc, errPos, key); + addEvents(events, doc, errPos, value); + }); + events.push('-MAP'); + } + else if (identity.isSeq(node)) { + const ev = node.flow ? '+SEQ []' : '+SEQ'; + events.push(`${ev}${props}`); + node.items.forEach(item => { + addEvents(events, doc, errPos, item); + }); + events.push('-SEQ'); + } + else if (identity.isPair(node)) { + events.push(`+MAP${props}`); + addEvents(events, doc, errPos, node.key); + addEvents(events, doc, errPos, node.value); + events.push('-MAP'); + } + else if (identity.isAlias(node)) { + let alias = node.source; + if (alias && /\d$/.test(alias)) { + const alt = alias.replace(/\d$/, ''); + if (anchorExists(doc, alt)) + alias = alt; + } + events.push(`=ALI${props} *${alias}`); + } + else { + const scalar = scalarChar[String(node.type)]; + if (!scalar) + throw new Error(`Unexpected node type ${node.type}`); + const value = node.source + .replace(/\\/g, '\\\\') + .replace(/\0/g, '\\0') + .replace(/\x07/g, '\\a') + .replace(/\x08/g, '\\b') + .replace(/\t/g, '\\t') + .replace(/\n/g, '\\n') + .replace(/\v/g, '\\v') + .replace(/\f/g, '\\f') + .replace(/\r/g, '\\r') + .replace(/\x1b/g, '\\e'); + events.push(`=VAL${props} ${scalar}${value}`); + } +} + +exports.testEvents = testEvents; diff --git a/bundled-addons/pithos/vendor/yaml/lib/util.js b/bundled-addons/pithos/vendor/yaml/lib/util.js new file mode 100644 index 00000000..ae3c4abc --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/util.js @@ -0,0 +1,28 @@ +'use strict'; + +var createNode = require('./doc/createNode.js'); +var log = require('./log.js'); +var Pair = require('./nodes/Pair.js'); +var toJS = require('./nodes/toJS.js'); +var YAMLMap = require('./nodes/YAMLMap.js'); +var map = require('./schema/common/map.js'); +var seq = require('./schema/common/seq.js'); +var string = require('./schema/common/string.js'); +var foldFlowLines = require('./stringify/foldFlowLines.js'); +var stringifyNumber = require('./stringify/stringifyNumber.js'); +var stringifyString = require('./stringify/stringifyString.js'); + + + +exports.createNode = createNode.createNode; +exports.debug = log.debug; +exports.warn = log.warn; +exports.createPair = Pair.createPair; +exports.toJS = toJS.toJS; +exports.findPair = YAMLMap.findPair; +exports.mapTag = map.map; +exports.seqTag = seq.seq; +exports.stringTag = string.string; +exports.foldFlowLines = foldFlowLines.foldFlowLines; +exports.stringifyNumber = stringifyNumber.stringifyNumber; +exports.stringifyString = stringifyString.stringifyString; diff --git a/bundled-addons/pithos/vendor/yaml/lib/visit.js b/bundled-addons/pithos/vendor/yaml/lib/visit.js new file mode 100644 index 00000000..f126e54f --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/lib/visit.js @@ -0,0 +1,236 @@ +'use strict'; + +var identity = require('./nodes/identity.js'); + +const BREAK = Symbol('break visit'); +const SKIP = Symbol('skip children'); +const REMOVE = Symbol('remove node'); +/** + * Apply a visitor to an AST node or document. + * + * Walks through the tree (depth-first) starting from `node`, calling a + * `visitor` function with three arguments: + * - `key`: For sequence values and map `Pair`, the node's index in the + * collection. Within a `Pair`, `'key'` or `'value'`, correspondingly. + * `null` for the root node. + * - `node`: The current node. + * - `path`: The ancestry of the current node. + * + * The return value of the visitor may be used to control the traversal: + * - `undefined` (default): Do nothing and continue + * - `visit.SKIP`: Do not visit the children of this node, continue with next + * sibling + * - `visit.BREAK`: Terminate traversal completely + * - `visit.REMOVE`: Remove the current node, then continue with the next one + * - `Node`: Replace the current node, then continue by visiting it + * - `number`: While iterating the items of a sequence or map, set the index + * of the next step. This is useful especially if the index of the current + * node has changed. + * + * If `visitor` is a single function, it will be called with all values + * encountered in the tree, including e.g. `null` values. Alternatively, + * separate visitor functions may be defined for each `Map`, `Pair`, `Seq`, + * `Alias` and `Scalar` node. To define the same visitor function for more than + * one node type, use the `Collection` (map and seq), `Value` (map, seq & scalar) + * and `Node` (alias, map, seq & scalar) targets. Of all these, only the most + * specific defined one will be used for each node. + */ +function visit(node, visitor) { + const visitor_ = initVisitor(visitor); + if (identity.isDocument(node)) { + const cd = visit_(null, node.contents, visitor_, Object.freeze([node])); + if (cd === REMOVE) + node.contents = null; + } + else + visit_(null, node, visitor_, Object.freeze([])); +} +// Without the `as symbol` casts, TS declares these in the `visit` +// namespace using `var`, but then complains about that because +// `unique symbol` must be `const`. +/** Terminate visit traversal completely */ +visit.BREAK = BREAK; +/** Do not visit the children of the current node */ +visit.SKIP = SKIP; +/** Remove the current node */ +visit.REMOVE = REMOVE; +function visit_(key, node, visitor, path) { + const ctrl = callVisitor(key, node, visitor, path); + if (identity.isNode(ctrl) || identity.isPair(ctrl)) { + replaceNode(key, path, ctrl); + return visit_(key, ctrl, visitor, path); + } + if (typeof ctrl !== 'symbol') { + if (identity.isCollection(node)) { + path = Object.freeze(path.concat(node)); + for (let i = 0; i < node.items.length; ++i) { + const ci = visit_(i, node.items[i], visitor, path); + if (typeof ci === 'number') + i = ci - 1; + else if (ci === BREAK) + return BREAK; + else if (ci === REMOVE) { + node.items.splice(i, 1); + i -= 1; + } + } + } + else if (identity.isPair(node)) { + path = Object.freeze(path.concat(node)); + const ck = visit_('key', node.key, visitor, path); + if (ck === BREAK) + return BREAK; + else if (ck === REMOVE) + node.key = null; + const cv = visit_('value', node.value, visitor, path); + if (cv === BREAK) + return BREAK; + else if (cv === REMOVE) + node.value = null; + } + } + return ctrl; +} +/** + * Apply an async visitor to an AST node or document. + * + * Walks through the tree (depth-first) starting from `node`, calling a + * `visitor` function with three arguments: + * - `key`: For sequence values and map `Pair`, the node's index in the + * collection. Within a `Pair`, `'key'` or `'value'`, correspondingly. + * `null` for the root node. + * - `node`: The current node. + * - `path`: The ancestry of the current node. + * + * The return value of the visitor may be used to control the traversal: + * - `Promise`: Must resolve to one of the following values + * - `undefined` (default): Do nothing and continue + * - `visit.SKIP`: Do not visit the children of this node, continue with next + * sibling + * - `visit.BREAK`: Terminate traversal completely + * - `visit.REMOVE`: Remove the current node, then continue with the next one + * - `Node`: Replace the current node, then continue by visiting it + * - `number`: While iterating the items of a sequence or map, set the index + * of the next step. This is useful especially if the index of the current + * node has changed. + * + * If `visitor` is a single function, it will be called with all values + * encountered in the tree, including e.g. `null` values. Alternatively, + * separate visitor functions may be defined for each `Map`, `Pair`, `Seq`, + * `Alias` and `Scalar` node. To define the same visitor function for more than + * one node type, use the `Collection` (map and seq), `Value` (map, seq & scalar) + * and `Node` (alias, map, seq & scalar) targets. Of all these, only the most + * specific defined one will be used for each node. + */ +async function visitAsync(node, visitor) { + const visitor_ = initVisitor(visitor); + if (identity.isDocument(node)) { + const cd = await visitAsync_(null, node.contents, visitor_, Object.freeze([node])); + if (cd === REMOVE) + node.contents = null; + } + else + await visitAsync_(null, node, visitor_, Object.freeze([])); +} +// Without the `as symbol` casts, TS declares these in the `visit` +// namespace using `var`, but then complains about that because +// `unique symbol` must be `const`. +/** Terminate visit traversal completely */ +visitAsync.BREAK = BREAK; +/** Do not visit the children of the current node */ +visitAsync.SKIP = SKIP; +/** Remove the current node */ +visitAsync.REMOVE = REMOVE; +async function visitAsync_(key, node, visitor, path) { + const ctrl = await callVisitor(key, node, visitor, path); + if (identity.isNode(ctrl) || identity.isPair(ctrl)) { + replaceNode(key, path, ctrl); + return visitAsync_(key, ctrl, visitor, path); + } + if (typeof ctrl !== 'symbol') { + if (identity.isCollection(node)) { + path = Object.freeze(path.concat(node)); + for (let i = 0; i < node.items.length; ++i) { + const ci = await visitAsync_(i, node.items[i], visitor, path); + if (typeof ci === 'number') + i = ci - 1; + else if (ci === BREAK) + return BREAK; + else if (ci === REMOVE) { + node.items.splice(i, 1); + i -= 1; + } + } + } + else if (identity.isPair(node)) { + path = Object.freeze(path.concat(node)); + const ck = await visitAsync_('key', node.key, visitor, path); + if (ck === BREAK) + return BREAK; + else if (ck === REMOVE) + node.key = null; + const cv = await visitAsync_('value', node.value, visitor, path); + if (cv === BREAK) + return BREAK; + else if (cv === REMOVE) + node.value = null; + } + } + return ctrl; +} +function initVisitor(visitor) { + if (typeof visitor === 'object' && + (visitor.Collection || visitor.Node || visitor.Value)) { + return Object.assign({ + Alias: visitor.Node, + Map: visitor.Node, + Scalar: visitor.Node, + Seq: visitor.Node + }, visitor.Value && { + Map: visitor.Value, + Scalar: visitor.Value, + Seq: visitor.Value + }, visitor.Collection && { + Map: visitor.Collection, + Seq: visitor.Collection + }, visitor); + } + return visitor; +} +function callVisitor(key, node, visitor, path) { + if (typeof visitor === 'function') + return visitor(key, node, path); + if (identity.isMap(node)) + return visitor.Map?.(key, node, path); + if (identity.isSeq(node)) + return visitor.Seq?.(key, node, path); + if (identity.isPair(node)) + return visitor.Pair?.(key, node, path); + if (identity.isScalar(node)) + return visitor.Scalar?.(key, node, path); + if (identity.isAlias(node)) + return visitor.Alias?.(key, node, path); + return undefined; +} +function replaceNode(key, path, node) { + const parent = path[path.length - 1]; + if (identity.isCollection(parent)) { + parent.items[key] = node; + } + else if (identity.isPair(parent)) { + if (key === 'key') + parent.key = node; + else + parent.value = node; + } + else if (identity.isDocument(parent)) { + parent.contents = node; + } + else { + const pt = identity.isAlias(parent) ? 'alias' : 'scalar'; + throw new Error(`Cannot replace node with ${pt} parent`); + } +} + +exports.visit = visit; +exports.visitAsync = visitAsync; diff --git a/bundled-addons/pithos/vendor/yaml/package.json b/bundled-addons/pithos/vendor/yaml/package.json new file mode 100644 index 00000000..f59c2095 --- /dev/null +++ b/bundled-addons/pithos/vendor/yaml/package.json @@ -0,0 +1,6 @@ +{ + "name": "yaml", + "version": "2.9.1", + "type": "commonjs", + "private": true +}