@quillmark/quiver 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,6 +68,8 @@ await build('./node_modules/@org/my-quiver', './public/quivers/my-quiver');
68
68
 
69
69
  `build` owns its output path outright: it assembles a generation in `<outDir>.stage`, moves it in whole, and deletes the one it replaced. A reader fetching mid-build sees the previous generation rather than a torn tree, and a build that throws leaves it serving. An `outDir` that is, or contains, the source quiver or the working directory is refused with a `transport_error` rather than deleted.
70
70
 
71
+ Quills below `0.1.0` are drafts and are left out: they stay in the source layout, which `fromDir` reads whole, and reach the artifact only under `build(src, out, { drafts: true })`. A quill with no version above the floor is absent from the built catalog entirely.
72
+
71
73
  ```ts
72
74
  // browser runtime
73
75
  import { Engine, init } from '@quillmark/wasm';
@@ -82,7 +84,7 @@ const quill = await quiver.getQuill(doc.quillRef);
82
84
  const result = await engine.render(quill, doc, { format: 'pdf' });
83
85
  ```
84
86
 
85
- A CDN URL works the same way, for consumers who cannot run a Node build step of their own.
87
+ A CDN URL works the same way, for consumers who cannot run a Node build step of their own — a collection you publish, on a host you pick, and pinned like the dependency [it is](#what-a-quiver-is-trusted-to-be).
86
88
 
87
89
  ## Server-side runtime (Node, packed artifact on disk)
88
90
 
@@ -124,6 +126,14 @@ The seed answers first and the URL serves what it does not carry. Seeding `lates
124
126
 
125
127
  `Quiver.fromBuiltUrl(url)` first fetches `<url>/latest.json`, a stable-named pointer to the current manifest. Everything behind that pointer is content-addressed and checked against the digest in its name; the pointer itself is not, so a cache layer can serve a stale one and silently pin the client to the old catalog. It is therefore the one request fetched `no-cache` (revalidate with the origin; a 304 still serves from disk), which closes the browser-cache layer. A stale CDN edge is answered by that host's cache headers.
126
128
 
129
+ The check itself needs `crypto.subtle`, which a browser exposes only in a secure context. An `https` page, `http://localhost` and Node have one; a page served over plain `http` to anything else — a dev host on a LAN address, a staging box without a certificate — has none, and there every fetch passes through unchecked.
130
+
131
+ ## What a quiver is trusted to be
132
+
133
+ **A quill is a template the backend executes, so loading a quiver runs its author's code.** Point one at a source you would take a dependency from — an npm package or a git tag, pinned like any other — because nothing here sandboxes a quill, and a collection assembled from anywhere else is a decision to make on purpose.
134
+
135
+ Content addressing is an integrity check and not a provenance one: the digest in each name catches a corrupted object, a partial sync and a name reused across releases, and says nothing about who packed the bytes. Where the primitive behind it is absent the page is no secure context either, so the pointer, the manifest, the digests and the client itself all arrived over the same unauthenticated channel — which is the whole of why a built quiver is served over `https`.
136
+
127
137
  ## Error handling
128
138
 
129
139
  Every error is a `QuiverError` carrying a `code` from a closed set — `invalid_ref`, `quill_not_found`, `quiver_invalid`, `transport_error` — a human-readable `message`, and the offending `ref` where there is one.
package/dist/build.d.ts CHANGED
@@ -5,6 +5,15 @@
5
5
  * a module reaching this one for types alone does not pull `node:fs` or
6
6
  * `node:crypto` into a browser bundle.
7
7
  */
8
+ /** Options for {@link buildQuiver}. */
9
+ export interface BuildOptions {
10
+ /**
11
+ * Pack versions below `MIN_PUBLISHED_VERSION` too. Off by default: the
12
+ * artifact is a deployment, and the draft space is not part of one. An
13
+ * author's own viewer turns it on, which is the only caller that should.
14
+ */
15
+ drafts?: boolean;
16
+ }
8
17
  /**
9
18
  * Reads a Source Quiver, validates it, and writes the build output to outDir.
10
19
  *
@@ -12,8 +21,8 @@
12
21
  * names, which is what the loader checks on fetch:
13
22
  * outDir/
14
23
  * latest.json # the format, and a stable pointer to the manifest
15
- * manifest.<sha256:12>.json # hashed manifest
16
- * <name>@<version>.<sha256:12>.zip # one bundle per quill
24
+ * manifest.<sha256:32>.json # hashed manifest
25
+ * <name>@<version>.<sha256:32>.zip # one bundle per quill
17
26
  * store/
18
27
  * <sha256> # dehydrated font bytes (full hash, no ext)
19
28
  *
@@ -21,9 +30,13 @@
21
30
  * fetching mid-build reads the previous one rather than a torn tree, and a
22
31
  * build that throws leaves the previous one serving.
23
32
  *
33
+ * Versions below `MIN_PUBLISHED_VERSION` are drafts and are left out unless
34
+ * `options.drafts` asks for them; a quill with nothing above the floor is
35
+ * absent from the manifest entirely.
36
+ *
24
37
  * Throws:
25
38
  * - `quiver_invalid` on source validation failures (propagated from scanner)
26
39
  * - `transport_error` on I/O failures, and on an outDir the build would have
27
40
  * to delete something it does not own to write (see `assertSafeOutDir`)
28
41
  */
29
- export declare function buildQuiver(sourceDir: string, outDir: string): Promise<void>;
42
+ export declare function buildQuiver(sourceDir: string, outDir: string, options?: BuildOptions): Promise<void>;
package/dist/build.js CHANGED
@@ -9,6 +9,7 @@ import { QuiverError } from './errors.js';
9
9
  import { packFiles } from './bundle.js';
10
10
  import { NAME_DIGEST_LENGTH } from './digest.js';
11
11
  import { MANIFEST_VERSION, POINTER_FORMAT } from './format.js';
12
+ import { isDraft } from './semver.js';
12
13
  /** Font file extensions recognised by the builder (case-insensitive). */
13
14
  const FONT_EXT = /\.(ttf|otf|woff|woff2)$/i;
14
15
  /** The two trees a build owns beside its output: where a generation is assembled, and
@@ -55,8 +56,8 @@ function assertSafeOutDir(path, sourceDir, outDir) {
55
56
  * names, which is what the loader checks on fetch:
56
57
  * outDir/
57
58
  * latest.json # the format, and a stable pointer to the manifest
58
- * manifest.<sha256:12>.json # hashed manifest
59
- * <name>@<version>.<sha256:12>.zip # one bundle per quill
59
+ * manifest.<sha256:32>.json # hashed manifest
60
+ * <name>@<version>.<sha256:32>.zip # one bundle per quill
60
61
  * store/
61
62
  * <sha256> # dehydrated font bytes (full hash, no ext)
62
63
  *
@@ -64,12 +65,16 @@ function assertSafeOutDir(path, sourceDir, outDir) {
64
65
  * fetching mid-build reads the previous one rather than a torn tree, and a
65
66
  * build that throws leaves the previous one serving.
66
67
  *
68
+ * Versions below `MIN_PUBLISHED_VERSION` are drafts and are left out unless
69
+ * `options.drafts` asks for them; a quill with nothing above the floor is
70
+ * absent from the manifest entirely.
71
+ *
67
72
  * Throws:
68
73
  * - `quiver_invalid` on source validation failures (propagated from scanner)
69
74
  * - `transport_error` on I/O failures, and on an outDir the build would have
70
75
  * to delete something it does not own to write (see `assertSafeOutDir`)
71
76
  */
72
- export async function buildQuiver(sourceDir, outDir) {
77
+ export async function buildQuiver(sourceDir, outDir, options = {}) {
73
78
  // Dynamic imports keep this module safe to type-import from browser contexts.
74
79
  const path = await import('node:path');
75
80
  const { dirname, join, resolve } = path;
@@ -110,6 +115,8 @@ export async function buildQuiver(sourceDir, outDir) {
110
115
  const manifestQuills = [];
111
116
  for (const [quillName, versions] of catalog) {
112
117
  for (const version of versions) {
118
+ if (!options.drafts && isDraft(version))
119
+ continue;
113
120
  const quillDir = join(sourceDir, 'quills', quillName, version);
114
121
  const tree = await readQuillTree(quillDir);
115
122
  const fontEntries = [];
@@ -139,7 +146,16 @@ export async function buildQuiver(sourceDir, outDir) {
139
146
  for (const [path, bytes] of contentEntries) {
140
147
  contentRecord[path] = bytes;
141
148
  }
142
- const zipBytes = packFiles(contentRecord);
149
+ // The budget `bundle.ts` carries is spent here as well as at the read, so
150
+ // a quill no loader would take is refused by the build that packs it,
151
+ // where the author it names can still do something about it.
152
+ let zipBytes;
153
+ try {
154
+ zipBytes = packFiles(contentRecord);
155
+ }
156
+ catch (err) {
157
+ throw new QuiverError('quiver_invalid', `Quill "${quillName}@${version}": ${err.message}`, { quiverName: meta.name, version, cause: err });
158
+ }
143
159
  const bundleHash = createHash('sha256')
144
160
  .update(zipBytes)
145
161
  .digest('hex')
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { QuiverError } from './errors.js';
9
9
  import { unpackFiles } from './bundle.js';
10
+ import { isQuillName } from './ref.js';
10
11
  import { isCanonicalSemver, compareSemver } from './semver.js';
11
12
  import { NAME_DIGEST_LENGTH, sha256Hex } from './digest.js';
12
13
  import { MANIFEST_VERSION, POINTER_FORMAT } from './format.js';
@@ -37,7 +38,9 @@ async function fetchVerified(transport, path, expected) {
37
38
  // chars for bundles and manifests and the full 64 for store entries.
38
39
  const DIGEST = `[0-9a-f]{${NAME_DIGEST_LENGTH},}`;
39
40
  const MANIFEST_FILENAME_RE = new RegExp(`^manifest\\.(${DIGEST})\\.json$`);
40
- const BUNDLE_FILENAME_RE = new RegExp(`^[A-Za-z0-9_.-]+@[0-9]+\\.[0-9]+\\.[0-9]+\\.(${DIGEST})\\.zip$`);
41
+ // The name span is the ref charset, not a filename charset: `build` spells a bundle
42
+ // off the catalog row, and a row is a name a ref can spell.
43
+ const BUNDLE_FILENAME_RE = new RegExp(`^[A-Za-z0-9_-]+@[0-9]+\\.[0-9]+\\.[0-9]+\\.(${DIGEST})\\.zip$`);
41
44
  const FONT_HASH_RE = /^[0-9a-f]{64}$/;
42
45
  /** The digest a content-addressed filename carries. Throws `quiver_invalid`. */
43
46
  function digestOfName(re, name, what) {
@@ -171,6 +174,9 @@ function parseManifest(raw) {
171
174
  if (typeof e['name'] !== 'string' || e['name'].length === 0) {
172
175
  throw new QuiverError('quiver_invalid', `manifest.quills[${i}].name must be a non-empty string`);
173
176
  }
177
+ if (!isQuillName(e['name'])) {
178
+ throw new QuiverError('quiver_invalid', `manifest.quills[${i}].name "${e['name']}" is not a name a ref can spell — only [A-Za-z0-9_-] are allowed`);
179
+ }
174
180
  if (typeof e['version'] !== 'string' || !isCanonicalSemver(e['version'])) {
175
181
  throw new QuiverError('quiver_invalid', `manifest.quills[${i}].version must be canonical semver (x.y.z), got "${String(e['version'])}"`);
176
182
  }
package/dist/bundle.d.ts CHANGED
@@ -4,10 +4,12 @@
4
4
  /**
5
5
  * Pack a flat file map into a deterministic zip.
6
6
  * Keys are sorted before zipping so insertion order doesn't affect output.
7
+ * Throws `quiver_invalid` where the budget is spent.
7
8
  */
8
9
  export declare function packFiles(files: Record<string, Uint8Array>): Uint8Array;
9
10
  /**
10
11
  * Unpack a zip into a flat file map.
11
12
  * Returns { path: Uint8Array } for every file entry in the archive.
13
+ * Throws `quiver_invalid` where the budget is spent, before any entry inflates.
12
14
  */
13
15
  export declare function unpackFiles(data: Uint8Array): Record<string, Uint8Array>;
package/dist/bundle.js CHANGED
@@ -2,6 +2,7 @@
2
2
  * Zip utilities — browser-safe (uses fflate only).
3
3
  */
4
4
  import { zipSync, unzipSync } from 'fflate';
5
+ import { QuiverError } from './errors.js';
5
6
  /**
6
7
  * Fixed epoch mtime for deterministic zip output.
7
8
  *
@@ -13,24 +14,75 @@ import { zipSync, unzipSync } from 'fflate';
13
14
  * is always identical.
14
15
  */
15
16
  const ZIP_EPOCH = new Date(1980, 0, 1, 0, 0, 0, 0);
17
+ /**
18
+ * What a bundle may hold. Deflate tops out near 1032:1, so a megabyte of zip is a
19
+ * gigabyte resident and the bundle is what a reader inflates to find that out.
20
+ *
21
+ * The ceiling is a constant rather than something a consumer raises, because a quiver
22
+ * is a dependency pinned like any other (README, "What a quiver is trusted to be") and
23
+ * what a budget bounds there is a build that packed something enormous. Both ends spend
24
+ * it, so an artifact no loader would read is refused where it is written.
25
+ *
26
+ * Fonts are not in here: `build` writes them to the store and the manifest names them,
27
+ * leaving a bundle its markdown, its Typst, and what those inline.
28
+ */
29
+ const MAX_BUNDLE_BYTES = 64 * 1024 * 1024;
30
+ const MAX_ENTRY_BYTES = 16 * 1024 * 1024;
31
+ const MAX_BUNDLE_ENTRIES = 2048;
32
+ /**
33
+ * The budget, spent one entry at a time. Throws `quiver_invalid`.
34
+ *
35
+ * Unpacking spends the sizes the central directory declares, and those are the sizes
36
+ * fflate allocates: each entry inflates into a buffer of exactly its declared size,
37
+ * which never grows. So a header cannot buy more than it declares — it can only
38
+ * understate itself and truncate its own entry, which is the corrupt bundle the digest
39
+ * already answers.
40
+ */
41
+ function bundleBudget() {
42
+ let total = 0;
43
+ let count = 0;
44
+ return (name, size) => {
45
+ if (++count > MAX_BUNDLE_ENTRIES) {
46
+ throw new QuiverError('quiver_invalid', `Bundle holds over ${MAX_BUNDLE_ENTRIES} files, the most one may hold`);
47
+ }
48
+ if (size > MAX_ENTRY_BYTES) {
49
+ throw new QuiverError('quiver_invalid', `Bundled file "${name}" is ${size} bytes, over the ${MAX_ENTRY_BYTES} one file may be`);
50
+ }
51
+ total += size;
52
+ if (total > MAX_BUNDLE_BYTES) {
53
+ throw new QuiverError('quiver_invalid', `Bundle unpacks to over ${MAX_BUNDLE_BYTES} bytes, the most one may hold`);
54
+ }
55
+ };
56
+ }
16
57
  /**
17
58
  * Pack a flat file map into a deterministic zip.
18
59
  * Keys are sorted before zipping so insertion order doesn't affect output.
60
+ * Throws `quiver_invalid` where the budget is spent.
19
61
  */
20
62
  export function packFiles(files) {
63
+ const spend = bundleBudget();
21
64
  const sorted = Object.keys(files).sort();
22
65
  const input = {};
23
66
  for (const key of sorted) {
24
- input[key] = [files[key], { mtime: ZIP_EPOCH }];
67
+ const bytes = files[key];
68
+ spend(key, bytes.length);
69
+ input[key] = [bytes, { mtime: ZIP_EPOCH }];
25
70
  }
26
71
  return zipSync(input, { level: 6 });
27
72
  }
28
73
  /**
29
74
  * Unpack a zip into a flat file map.
30
75
  * Returns { path: Uint8Array } for every file entry in the archive.
76
+ * Throws `quiver_invalid` where the budget is spent, before any entry inflates.
31
77
  */
32
78
  export function unpackFiles(data) {
33
- const raw = unzipSync(data);
79
+ const spend = bundleBudget();
80
+ const raw = unzipSync(data, {
81
+ filter: ({ name, originalSize }) => {
82
+ spend(name, originalSize);
83
+ return true;
84
+ }
85
+ });
34
86
  const result = {};
35
87
  for (const [key, value] of Object.entries(raw)) {
36
88
  result[key] = value;
package/dist/digest.d.ts CHANGED
@@ -9,11 +9,15 @@
9
9
  * have one.
10
10
  */
11
11
  /**
12
- * Digest width in the names `build` writes, in hex chars: 48 bits of SHA-256.
13
- * Wide enough that a manifest name colliding with a prior release's, which
14
- * under immutable CDN caching serves the old catalog forever, is not a birthday
15
- * problem within any release count a quiver will see.
12
+ * Digest width in the names `build` writes, in hex chars: 128 bits of SHA-256.
13
+ *
14
+ * The width answers a chosen prefix, not a collision. Where the manifest is trusted
15
+ * and the bundles are not — the seeded deployment, where the two come from different
16
+ * places by design — this is the only thing between a served zip and the quill it
17
+ * claims to be, and a quill is a template the backend executes. Grinding padding until
18
+ * SHA-256 lands on a chosen prefix costs hours of one GPU at 48 bits and is out of
19
+ * reach at 128.
16
20
  */
17
- export declare const NAME_DIGEST_LENGTH = 12;
21
+ export declare const NAME_DIGEST_LENGTH = 32;
18
22
  /** SHA-256 over `bytes` as lowercase hex, or undefined where no digest exists. */
19
23
  export declare function sha256Hex(bytes: Uint8Array): Promise<string | undefined>;
package/dist/digest.js CHANGED
@@ -9,12 +9,16 @@
9
9
  * have one.
10
10
  */
11
11
  /**
12
- * Digest width in the names `build` writes, in hex chars: 48 bits of SHA-256.
13
- * Wide enough that a manifest name colliding with a prior release's, which
14
- * under immutable CDN caching serves the old catalog forever, is not a birthday
15
- * problem within any release count a quiver will see.
12
+ * Digest width in the names `build` writes, in hex chars: 128 bits of SHA-256.
13
+ *
14
+ * The width answers a chosen prefix, not a collision. Where the manifest is trusted
15
+ * and the bundles are not — the seeded deployment, where the two come from different
16
+ * places by design — this is the only thing between a served zip and the quill it
17
+ * claims to be, and a quill is a template the backend executes. Grinding padding until
18
+ * SHA-256 lands on a chosen prefix costs hours of one GPU at 48 bits and is out of
19
+ * reach at 128.
16
20
  */
17
- export const NAME_DIGEST_LENGTH = 12;
21
+ export const NAME_DIGEST_LENGTH = 32;
18
22
  /** SHA-256 over `bytes` as lowercase hex, or undefined where no digest exists. */
19
23
  export async function sha256Hex(bytes) {
20
24
  const subtle = globalThis.crypto?.subtle;
package/dist/node.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * makes no static or dynamic reference to it.
10
10
  */
11
11
  import { Quiver } from './quiver.js';
12
+ import type { BuildOptions } from './build.js';
12
13
  /**
13
14
  * Reads a Source Quiver from a local directory containing `Quiver.yaml` and
14
15
  * `quills/<name>/<version>/Quill.yaml` entries.
@@ -42,10 +43,14 @@ export declare function fromBuiltDir(dirPath: string): Promise<Quiver>;
42
43
  * Reads the Source Quiver at sourceDir, validates it, and writes the runtime
43
44
  * build artifact to outDir, which the build clears first and therefore owns.
44
45
  *
46
+ * Quills below `0.1.0` are drafts: they stay in the source layout, which
47
+ * `fromDir` reads whole, and reach the artifact only under `{ drafts: true }`.
48
+ *
45
49
  * Throws `quiver_invalid` on source validation failures, `transport_error` on
46
50
  * I/O failures and on an outDir holding the source quiver or the cwd.
47
51
  */
48
- export declare function build(sourceDir: string, outDir: string): Promise<void>;
52
+ export declare function build(sourceDir: string, outDir: string, options?: BuildOptions): Promise<void>;
49
53
  export { Quiver } from './quiver.js';
50
54
  export { QuiverError } from './errors.js';
51
55
  export type { QuiverErrorCode } from './errors.js';
56
+ export type { BuildOptions } from './build.js';
package/dist/node.js CHANGED
@@ -55,11 +55,14 @@ export async function fromBuiltDir(dirPath) {
55
55
  * Reads the Source Quiver at sourceDir, validates it, and writes the runtime
56
56
  * build artifact to outDir, which the build clears first and therefore owns.
57
57
  *
58
+ * Quills below `0.1.0` are drafts: they stay in the source layout, which
59
+ * `fromDir` reads whole, and reach the artifact only under `{ drafts: true }`.
60
+ *
58
61
  * Throws `quiver_invalid` on source validation failures, `transport_error` on
59
62
  * I/O failures and on an outDir holding the source quiver or the cwd.
60
63
  */
61
- export async function build(sourceDir, outDir) {
62
- return buildQuiver(sourceDir, outDir);
64
+ export async function build(sourceDir, outDir, options) {
65
+ return buildQuiver(sourceDir, outDir, options);
63
66
  }
64
67
  // The rest of the public surface, so a Node consumer needs one import.
65
68
  export { Quiver } from './quiver.js';
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { parse as parseYaml } from 'yaml';
7
7
  import { QuiverError } from './errors.js';
8
- const NAME_RE = /^[A-Za-z0-9_-]+$/;
8
+ import { isQuillName } from './ref.js';
9
9
  const KNOWN_FIELDS = new Set(['name', 'description']);
10
10
  /**
11
11
  * Parses and validates Quiver.yaml contents.
@@ -42,7 +42,7 @@ export function parseQuiverYaml(raw) {
42
42
  throw new QuiverError('quiver_invalid', `Quiver.yaml: "name" must be a string, got ${typeof doc['name']}`);
43
43
  }
44
44
  const name = doc['name'];
45
- if (!NAME_RE.test(name)) {
45
+ if (!isQuillName(name)) {
46
46
  throw new QuiverError('quiver_invalid', `Quiver.yaml: "name" value "${name}" contains invalid characters — only [A-Za-z0-9_-] are allowed`);
47
47
  }
48
48
  if ('description' in doc && doc['description'] !== undefined) {
package/dist/ref.d.ts CHANGED
@@ -6,6 +6,12 @@ export interface ParsedQuillRef {
6
6
  /** Selector part count: 1 = `x`, 2 = `x.y`, 3 = `x.y.z` (exact). */
7
7
  selectorDepth?: 1 | 2 | 3;
8
8
  }
9
+ /**
10
+ * Whether `name` is a name a ref can spell. A quiver's own name and every catalog
11
+ * row are held to it: a row seated outside it is one `quillNames` lists and
12
+ * `getQuill` refuses.
13
+ */
14
+ export declare function isQuillName(name: string): boolean;
9
15
  /**
10
16
  * Throws QuiverError('invalid_ref') on malformed input. The name is
11
17
  * `[A-Za-z0-9_-]+`; the selector is `x`, `x.y` or `x.y.z`, digits only, with no
package/dist/ref.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import { QuiverError } from './errors.js';
2
2
  const NAME_RE = /^[A-Za-z0-9_-]+$/;
3
3
  const SELECTOR_RE = /^\d+(\.\d+){0,2}$/;
4
+ /**
5
+ * Whether `name` is a name a ref can spell. A quiver's own name and every catalog
6
+ * row are held to it: a row seated outside it is one `quillNames` lists and
7
+ * `getQuill` refuses.
8
+ */
9
+ export function isQuillName(name) {
10
+ return NAME_RE.test(name);
11
+ }
4
12
  /**
5
13
  * Throws QuiverError('invalid_ref') on malformed input. The name is
6
14
  * `[A-Za-z0-9_-]+`; the selector is `x`, `x.y` or `x.y.z`, digits only, with no
package/dist/semver.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /** The lowest version a build packs. Below it is the draft space. */
2
+ export declare const MIN_PUBLISHED_VERSION = "0.1.0";
1
3
  /** Returns true for exactly `x.y.z` with non-negative integer parts. */
2
4
  export declare function isCanonicalSemver(version: string): boolean;
3
5
  /**
@@ -8,5 +10,7 @@ export declare function isCanonicalSemver(version: string): boolean;
8
10
  export declare function matchesSemverSelector(version: string, selector: string): boolean;
9
11
  /** Compares two canonical semver strings. Returns <0, 0, or >0. */
10
12
  export declare function compareSemver(a: string, b: string): number;
13
+ /** Returns true for a canonical version below `MIN_PUBLISHED_VERSION`. */
14
+ export declare function isDraft(version: string): boolean;
11
15
  /** Returns the highest version string, or null if empty. */
12
16
  export declare function chooseHighestVersion(versions: string[]): string | null;
package/dist/semver.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** The lowest version a build packs. Below it is the draft space. */
2
+ export const MIN_PUBLISHED_VERSION = '0.1.0';
1
3
  /** Returns true for exactly `x.y.z` with non-negative integer parts. */
2
4
  export function isCanonicalSemver(version) {
3
5
  return /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version);
@@ -33,6 +35,10 @@ export function compareSemver(a, b) {
33
35
  }
34
36
  return 0;
35
37
  }
38
+ /** Returns true for a canonical version below `MIN_PUBLISHED_VERSION`. */
39
+ export function isDraft(version) {
40
+ return compareSemver(version, MIN_PUBLISHED_VERSION) < 0;
41
+ }
36
42
  /** Returns the highest version string, or null if empty. */
37
43
  export function chooseHighestVersion(versions) {
38
44
  if (versions.length === 0)
@@ -11,8 +11,10 @@ import type { QuiverLoader } from './quiver.js';
11
11
  * to build a catalog of quill names → sorted versions (descending).
12
12
  *
13
13
  * Throws:
14
- * - `quiver_invalid` if Quiver.yaml is missing/invalid, a version dir name is
15
- * non-canonical, or a version dir is missing its Quill.yaml sentinel.
14
+ * - `quiver_invalid` if Quiver.yaml is missing/invalid, a quill or version directory
15
+ * is a symlink, a quill dir holding versions is named outside the ref charset, a
16
+ * version dir name is non-canonical, or a version dir is missing its Quill.yaml
17
+ * sentinel.
16
18
  * - `transport_error` for I/O failures (permissions, etc.).
17
19
  *
18
20
  * Missing `quills/` directory is not an error — the quiver is valid but empty.
@@ -25,7 +27,7 @@ export declare function scanSourceQuiver(rootDir: string): Promise<{
25
27
  * Recursively reads all files under a quill version directory into a Map.
26
28
  *
27
29
  * Keys are relative POSIX paths (forward slashes, no leading slash).
28
- * Throws `transport_error` on I/O failure.
30
+ * Throws `quiver_invalid` on a symlink, `transport_error` on I/O failure.
29
31
  */
30
32
  export declare function readQuillTree(quillDir: string): Promise<Map<string, Uint8Array>>;
31
33
  /**
@@ -2,11 +2,24 @@
2
2
  * Internal filesystem scanner for the Source Quiver layout. Statically imports
3
3
  * `node:fs`, so it is reachable from the Node entry alone.
4
4
  */
5
- import { readdir, readFile, stat } from 'node:fs/promises';
5
+ import { readdir, readFile, lstat, stat } from 'node:fs/promises';
6
6
  import { join, relative, sep } from 'node:path';
7
7
  import { QuiverError } from './errors.js';
8
8
  import { parseQuiverYaml } from './quiver-yaml.js';
9
+ import { isQuillName } from './ref.js';
9
10
  import { isCanonicalSemver, compareSemver } from './semver.js';
11
+ /**
12
+ * Refuse a symlink. Every rung that opens a tree or reads a file `lstat`s and asks,
13
+ * because a source quiver is content and a link is a reference out of it: followed,
14
+ * `readFile` reads whatever it points at into the quill, where the backend can typeset
15
+ * it and `build` packs it into the published artifact. A linked directory smuggles a
16
+ * tree where a linked file smuggles one, so the answer is the same at each.
17
+ */
18
+ function refuseSymlink(st, path) {
19
+ if (st.isSymbolicLink()) {
20
+ throw new QuiverError('quiver_invalid', `"${path}" is a symlink; a quiver holds its own content, and nothing under it may point outside`);
21
+ }
22
+ }
10
23
  /**
11
24
  * Scans a Source Quiver root directory.
12
25
  *
@@ -14,8 +27,10 @@ import { isCanonicalSemver, compareSemver } from './semver.js';
14
27
  * to build a catalog of quill names → sorted versions (descending).
15
28
  *
16
29
  * Throws:
17
- * - `quiver_invalid` if Quiver.yaml is missing/invalid, a version dir name is
18
- * non-canonical, or a version dir is missing its Quill.yaml sentinel.
30
+ * - `quiver_invalid` if Quiver.yaml is missing/invalid, a quill or version directory
31
+ * is a symlink, a quill dir holding versions is named outside the ref charset, a
32
+ * version dir name is non-canonical, or a version dir is missing its Quill.yaml
33
+ * sentinel.
19
34
  * - `transport_error` for I/O failures (permissions, etc.).
20
35
  *
21
36
  * Missing `quills/` directory is not an error — the quiver is valid but empty.
@@ -56,11 +71,12 @@ export async function scanSourceQuiver(rootDir) {
56
71
  const quillNameDir = join(quillsDir, quillName);
57
72
  let st;
58
73
  try {
59
- st = await stat(quillNameDir);
74
+ st = await lstat(quillNameDir);
60
75
  }
61
76
  catch (err) {
62
77
  throw new QuiverError('transport_error', `Failed to stat "${quillNameDir}": ${err.message}`, { cause: err });
63
78
  }
79
+ refuseSymlink(st, quillNameDir);
64
80
  if (!st.isDirectory())
65
81
  continue;
66
82
  let versionDirs;
@@ -75,11 +91,12 @@ export async function scanSourceQuiver(rootDir) {
75
91
  const versionPath = join(quillNameDir, versionDir);
76
92
  let vst;
77
93
  try {
78
- vst = await stat(versionPath);
94
+ vst = await lstat(versionPath);
79
95
  }
80
96
  catch (err) {
81
97
  throw new QuiverError('transport_error', `Failed to stat "${versionPath}": ${err.message}`, { cause: err });
82
98
  }
99
+ refuseSymlink(vst, versionPath);
83
100
  if (!vst.isDirectory())
84
101
  continue;
85
102
  if (!isCanonicalSemver(versionDir)) {
@@ -99,6 +116,11 @@ export async function scanSourceQuiver(rootDir) {
99
116
  versions.push(versionDir);
100
117
  }
101
118
  if (versions.length > 0) {
119
+ // Asked here rather than at the directory, so a stray one holding no quill
120
+ // stays ignored: what has to be addressable is a catalog row.
121
+ if (!isQuillName(quillName)) {
122
+ throw new QuiverError('quiver_invalid', `Quill directory "${quillName}" is not a name a ref can spell — only [A-Za-z0-9_-] are allowed`, { quiverName: meta.name });
123
+ }
102
124
  versions.sort((a, b) => compareSemver(b, a));
103
125
  catalog.set(quillName, versions);
104
126
  }
@@ -109,7 +131,7 @@ export async function scanSourceQuiver(rootDir) {
109
131
  * Recursively reads all files under a quill version directory into a Map.
110
132
  *
111
133
  * Keys are relative POSIX paths (forward slashes, no leading slash).
112
- * Throws `transport_error` on I/O failure.
134
+ * Throws `quiver_invalid` on a symlink, `transport_error` on I/O failure.
113
135
  */
114
136
  export async function readQuillTree(quillDir) {
115
137
  const tree = new Map();
@@ -128,11 +150,12 @@ async function walkDir(baseDir, currentDir, tree) {
128
150
  const fullPath = join(currentDir, entry);
129
151
  let st;
130
152
  try {
131
- st = await stat(fullPath);
153
+ st = await lstat(fullPath);
132
154
  }
133
155
  catch (err) {
134
156
  throw new QuiverError('transport_error', `Failed to stat "${fullPath}": ${err.message}`, { cause: err });
135
157
  }
158
+ refuseSymlink(st, fullPath);
136
159
  if (st.isDirectory()) {
137
160
  await walkDir(baseDir, fullPath, tree);
138
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/quiver",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "A quiver of Quillmark quills: load one, pack one, hand out quills.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -42,7 +42,7 @@
42
42
  "NOTICE"
43
43
  ],
44
44
  "peerDependencies": {
45
- "@quillmark/wasm": ">=0.105.0-0"
45
+ "@quillmark/wasm": ">=0.108.0-0"
46
46
  },
47
47
  "dependencies": {
48
48
  "fflate": "^0.8.2",