diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 44d853f..a3c43d2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,6 +6,7 @@ on: paths: - 'docs/**' - 'tools/publisher/**' + - 'tools/fonts/**' - 'README*.md' - 'mkdocs.yml' - '.github/workflows/docs.yml' @@ -13,6 +14,7 @@ on: paths: - 'docs/**' - 'tools/publisher/**' + - 'tools/fonts/**' - 'README*.md' - 'mkdocs.yml' - '.github/workflows/docs.yml' @@ -40,6 +42,9 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22 + - name: Verify DDS font artifact adapter + run: node --test tools/fonts/prepare.test.mjs + - name: Check shared publisher attribution run: | npm ci --prefix tools/publisher --ignore-scripts diff --git a/.gitignore b/.gitignore index ea347d9..43af9c0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,9 @@ out/ ### Planning docs (local only) ### devslab-kit-planning.md + +# Generated by the pinned DDS font artifact +/docs/assets/dds-fonts/ +/docs/overrides/partials/dds-fonts.html +/.cache/dds-fonts/ +/tools/fonts/__pycache__/ diff --git a/docs/assets/fonts/pretendard/LICENSE.txt b/docs/assets/fonts/pretendard/LICENSE.txt deleted file mode 100644 index 497b88f..0000000 --- a/docs/assets/fonts/pretendard/LICENSE.txt +++ /dev/null @@ -1,94 +0,0 @@ -Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard), -with Reserved Font Name Pretendard. - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/assets/fonts/pretendard/SOURCE.txt b/docs/assets/fonts/pretendard/SOURCE.txt deleted file mode 100644 index 1a3fe3c..0000000 --- a/docs/assets/fonts/pretendard/SOURCE.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Pretendard Variable - -Unmodified Pretendard 1.3.9 variable WOFF2, copyright Kil Hyung-jin. - -- Upstream: https://github.com/orioncactus/pretendard/tree/v1.3.9 -- Original filename: PretendardVariable.woff2 -- License: SIL Open Font License 1.1; see LICENSE.txt. -- Bytes: 2057688 -- SHA-256: 9599f12fd42fc0bce1cd50b47a0c022e108d7aa64dd0d1bb0ed44f3282d900b4 - -The filename contains a content-hash prefix so a future font update uses a new URL. diff --git a/docs/overrides/main.html b/docs/overrides/main.html index e280ef4..705b1b5 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -2,7 +2,7 @@ {% block extrahead %} {{ super() }} - + {% include "partials/dds-fonts.html" %} {% endblock %} {% block site_meta %} diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 982b2c9..88ce75f 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -6,7 +6,7 @@ * - Dark mode: zinc-900 header, zinc-900 body, white text/logo * - Visual separation between header and body is a hairline border only * - Cyan-electric used as a small accent (links, hovers, active tab) - * - Pretendard typography (same family as devslab.kr) + * - Typography supplied by DDS * - Bilingual "EN / 한" language switcher (no CJK glyph, no generic globe) * * Material's default paints the header in primary color. We override the @@ -14,26 +14,10 @@ * block at the top — just navigation sitting cleanly above content. * --------------------------------------------------------------------------- */ -/* === Typography: Pretendard (same family as devslab.kr) =================== */ -/* One complete font face keeps Korean glyph selection atomic. Late loads - * retain the system fallback for this visit instead of swapping text. */ -@font-face { - font-family: "Pretendard"; - font-style: normal; - font-weight: 45 920; - font-display: optional; - src: url("../assets/fonts/pretendard/PretendardVariable-9599f12fd42f.woff2") format("woff2"); -} - +/* === Typography: supplied by DDS ====================================== */ :root { - --md-text-font: "Pretendard", - -apple-system, - "Apple SD Gothic Neo", - "Noto Sans KR", - system-ui, - sans-serif; - --md-code-font: "JetBrains Mono", "SFMono-Regular", Menlo, - Consolas, "Liberation Mono", monospace; + --md-text-font: var(--dds-font-family-sans); + --md-code-font: var(--dds-font-family-mono); } /* === Palette (light scheme) ============================================== diff --git a/mkdocs.yml b/mkdocs.yml index ca6827c..0d49ee4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,9 @@ edit_uri: edit/main/docs/ docs_dir: docs +hooks: + - tools/fonts/mkdocs_hook.py + theme: name: material font: false diff --git a/tools/fonts/README.md b/tools/fonts/README.md new file mode 100644 index 0000000..5018097 --- /dev/null +++ b/tools/fonts/README.md @@ -0,0 +1,9 @@ +# DDS documentation fonts + +The only font source is the private `@devslab/family-fonts` artifact produced by DDS. The vendored archive is pinned by SHA256 and SHA512 integrity in `artifact.json`. + +`python -m mkdocs build --strict` invokes the MkDocs hook before file discovery. The Node adapter verifies the archive, rejects unsafe tar entries, and runs the package exporter. It contains no font faces, family stacks, preload lists, or font copies. + +Generated assets, head partial and temporary extraction files are ignored. Update the shared artifact and its verified pin together; do not edit generated font definitions or add service-specific fonts. + +Run `node --test tools/fonts/prepare.test.mjs` for the archive integrity and safe-extraction contract. diff --git a/tools/fonts/artifact.json b/tools/fonts/artifact.json new file mode 100644 index 0000000..1e21982 --- /dev/null +++ b/tools/fonts/artifact.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "name": "@devslab/family-fonts", + "version": "0.0.0-dds.8c931915f9ed", + "file": "vendor/devslab-family-fonts-a162eeae087a.tgz", + "sha256": "a162eeae087ad2304edb45cfbedaaed0d0d763988f12acb81f830857f0854b9b", + "integrity": "sha512-VX662QJQN+Rbb7bLWffoSZxLeiZfHE5YBU5+NgF5iFAAmPj5PNFmg0r/YA6QPw0pFlBQoHugt4Am2t086B0RPw==", + "source": { + "repository": "https://github.com/devslab-kr/dds", + "ref": "49baacb7aeaf0ea49dadb30c3e5a50b6bb1bf0eb", + "contentSha256": "8c931915f9ed98381ce74041334dd694cb74795a060b31055dccb36bf0136c44" + }, + "distribution": "private-vendored" +} diff --git a/tools/fonts/mkdocs_hook.py b/tools/fonts/mkdocs_hook.py new file mode 100644 index 0000000..ad6486b --- /dev/null +++ b/tools/fonts/mkdocs_hook.py @@ -0,0 +1,7 @@ +"""Generate documentation font assets from the pinned DDS package before file discovery.""" +from pathlib import Path +import subprocess + +def on_pre_build(config, **kwargs): + root = Path(__file__).resolve().parents[2] + subprocess.run(["node", str(root / "tools/fonts/prepare.mjs")], cwd=root, check=True) diff --git a/tools/fonts/prepare.mjs b/tools/fonts/prepare.mjs new file mode 100644 index 0000000..4ed581c --- /dev/null +++ b/tools/fonts/prepare.mjs @@ -0,0 +1,110 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { gunzipSync } from 'node:zlib'; +import { execFileSync } from 'node:child_process'; + +const MAX_TAR_BYTES = 32 * 1024 * 1024; +const inside = (parent, child) => { + const rel = path.relative(parent, child); + return rel !== '' && !rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel); +}; + +export function verifyArtifact(bytes, pin) { + if (!/^[a-f0-9]{64}$/.test(pin.sha256 ?? '') || + !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(pin.integrity ?? '')) { + throw new Error('DDS artifact pin must contain SHA256 and sha512 integrity'); + } + const sha256 = createHash('sha256').update(bytes).digest('hex'); + const integrity = 'sha512-' + createHash('sha512').update(bytes).digest('base64'); + if (sha256 !== pin.sha256 || integrity !== pin.integrity) { + throw new Error('DDS artifact integrity mismatch'); + } +} + +export function extractPackage(bytes, destination) { + const tar = gunzipSync(bytes, { maxOutputLength: MAX_TAR_BYTES }); + const seen = new Set(); + let offset = 0, count = 0, ended = false; + const string = (block, start, length) => block.subarray(start, start + length).toString('utf8').replace(/\0.*$/s, ''); + const octal = value => { + const text = value.replace(/\0.*$/s, '').trim(); + if (!/^[0-7]+$/.test(text)) throw new Error('Unsupported tar numeric field'); + return Number.parseInt(text, 8); + }; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every(byte => byte === 0)) { + if (tar.subarray(offset).some(byte => byte !== 0)) throw new Error('Unexpected data after tar trailer'); + ended = true; + break; + } + if (++count > 512) throw new Error('DDS archive has too many entries'); + const checksum = octal(string(header, 148, 8)); + const actual = header.reduce((sum, byte, i) => sum + (i >= 148 && i < 156 ? 32 : byte), 0); + if (checksum !== actual) throw new Error('Invalid tar header checksum'); + const name = [string(header, 345, 155), string(header, 0, 100)].filter(Boolean).join('/'); + const type = String.fromCharCode(header[156]); + const size = octal(string(header, 124, 12)); + if (!Number.isSafeInteger(size) || size < 0 || size > MAX_TAR_BYTES || + offset + 512 + size > tar.length) throw new Error('Invalid tar entry size'); + if (!['0', '\0', '5'].includes(type)) throw new Error('DDS archive links and extended entry types are forbidden'); + if (name.includes('\\') || name.startsWith('/') || /^[A-Za-z]:/.test(name) || + name.split('/').some(part => part === '..' || part === '.')) { + throw new Error('Unsafe DDS archive path'); + } + if (name !== 'package/' && name !== 'package' && !name.startsWith('package/')) { + throw new Error('DDS archive entries must use the npm package/ prefix'); + } + const relative = name.replace(/^package\/?/, '').replace(/\/$/, ''); + if (relative) { + const target = path.resolve(destination, ...relative.split('/')); + if (!inside(path.resolve(destination), target) || seen.has(relative)) throw new Error('Unsafe or duplicate DDS archive path'); + seen.add(relative); + if (type === '5') { + if (size !== 0) throw new Error('Invalid tar directory size'); + fs.mkdirSync(target, { recursive: true }); + } else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, tar.subarray(offset + 512, offset + 512 + size), { flag: 'wx' }); + } + } else if (type !== '5') { + throw new Error('DDS archive package root must be a directory'); + } + offset += 512 + Math.ceil(size / 512) * 512; + } + if (!ended || !count) throw new Error('Incomplete DDS tar archive'); +} + +export function prepare(root = fileURLToPath(new URL('../../', import.meta.url))) { + const tooling = path.join(root, 'tools', 'fonts'); + const pin = JSON.parse(fs.readFileSync(path.join(tooling, 'artifact.json'), 'utf8')); + const archive = path.resolve(tooling, pin.file ?? ''); + const vendor = path.join(tooling, 'vendor'); + if (!inside(vendor, archive) || path.extname(archive) !== '.tgz') throw new Error('DDS artifact must be vendored under tools/fonts/vendor'); + const bytes = fs.readFileSync(archive); + verifyArtifact(bytes, pin); + const cache = path.join(root, '.cache', 'dds-fonts'); + fs.mkdirSync(cache, { recursive: true }); + const temporary = fs.mkdtempSync(path.join(cache, 'package-')); + try { + extractPackage(bytes, temporary); + const pkg = JSON.parse(fs.readFileSync(path.join(temporary, 'package.json'), 'utf8')); + if (pkg.name !== '@devslab/family-fonts' || pkg.private !== true || pkg.version !== pin.version) throw new Error('Unexpected DDS font package identity'); + execFileSync(process.execPath, [ + path.join(temporary, 'export.mjs'), + '--out-dir', 'docs/assets/dds-fonts', + '--base-url', '/assets/dds-fonts', + '--head-output', 'docs/overrides/partials/dds-fonts.html', + '--monospace', + ], { cwd: root, stdio: 'inherit' }); + } finally { + if (!inside(cache, temporary) || !path.basename(temporary).startsWith('package-')) throw new Error('Unsafe DDS temporary cleanup target'); + fs.rmSync(temporary, { recursive: true, force: true }); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { prepare(); } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/tools/fonts/prepare.test.mjs b/tools/fonts/prepare.test.mjs new file mode 100644 index 0000000..32d4e66 --- /dev/null +++ b/tools/fonts/prepare.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { gzipSync } from 'node:zlib'; +import { createHash } from 'node:crypto'; +import { extractPackage, verifyArtifact } from './prepare.mjs'; + +function archive(entries) { + const blocks = []; + for (const [name, body = '', type = '0'] of entries) { + const bytes = Buffer.from(body), h = Buffer.alloc(512); + h.write(name, 0, 100); + h.write('0000644\0', 100); h.write('0000000\0', 108); h.write('0000000\0', 116); + h.write(bytes.length.toString(8).padStart(11, '0') + '\0', 124); + h.write('00000000000\0', 136); h.fill(32, 148, 156); h.write(type, 156); + h.write('ustar\0', 257); h.write('00', 263); + const checksum = h.reduce((sum, value) => sum + value, 0); + h.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148); + blocks.push(h, bytes, Buffer.alloc((512 - bytes.length % 512) % 512)); + } + return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])); +} + +function temporary(run) { + const base = fs.realpathSync(os.tmpdir()); + const dir = fs.mkdtempSync(path.join(base, 'dds-font-contract-')); + try { return run(dir); } finally { + assert.equal(path.dirname(dir), base); + assert.ok(path.basename(dir).startsWith('dds-font-contract-')); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('verified npm archive preserves complete file bytes', () => temporary(dir => { + const bytes = archive([['package/package.json', '{"name":"@devslab/family-fonts"}'], ['package/assets/', '', '5'], ['package/assets/font.woff2', 'complete-font-bytes']]); + const pin = { sha256: createHash('sha256').update(bytes).digest('hex'), integrity: 'sha512-' + createHash('sha512').update(bytes).digest('base64') }; + verifyArtifact(bytes, pin); + extractPackage(bytes, dir); + assert.equal(fs.readFileSync(path.join(dir, 'assets/font.woff2'), 'utf8'), 'complete-font-bytes'); + assert.throws(() => verifyArtifact(Buffer.concat([bytes, Buffer.from('tampered')]), pin), /integrity mismatch/); + assert.throws(() => verifyArtifact(bytes, { sha256: pin.sha256 }), /pin/); +})); + +test('path traversal, links, extended entries and duplicate files are rejected', () => { + const entries = [ + [['package/../../outside', 'blocked']], + [['/outside', 'blocked']], + [['package\\..\\outside', 'blocked']], + [['other/file', 'blocked']], + [['package/link', '', '2']], + [['package/link', '', '1']], + [['package/pax', '', 'x']], + [['package/file', 'first'], ['package/file', 'second']], + ]; + for (const entry of entries) temporary(dir => assert.throws(() => extractPackage(archive(entry), dir))); +}); + +test('corrupt checksum and truncated archives fail closed', () => { + // Alter the tar header while retaining a valid gzip envelope. + const bytes = archive([['package/file', 'data']]); + return import('node:zlib').then(({ gunzipSync }) => { + const raw = gunzipSync(bytes); raw[0] ^= 1; + temporary(dir => assert.throws(() => extractPackage(gzipSync(raw), dir), /checksum/)); + temporary(dir => assert.throws(() => extractPackage(gzipSync(Buffer.alloc(511)), dir), /Incomplete/)); + }); +}); diff --git a/docs/assets/fonts/pretendard/PretendardVariable-9599f12fd42f.woff2 b/tools/fonts/vendor/devslab-family-fonts-a162eeae087a.tgz similarity index 87% rename from docs/assets/fonts/pretendard/PretendardVariable-9599f12fd42f.woff2 rename to tools/fonts/vendor/devslab-family-fonts-a162eeae087a.tgz index 49c54b5..66939ba 100644 Binary files a/docs/assets/fonts/pretendard/PretendardVariable-9599f12fd42f.woff2 and b/tools/fonts/vendor/devslab-family-fonts-a162eeae087a.tgz differ