@gmickel/gno 1.32.0 → 1.34.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.
Files changed (54) hide show
  1. package/README.md +17 -3
  2. package/assets/skill/SKILL.md +30 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +5 -1
  8. package/spec/cli.md +60 -1
  9. package/spec/mcp.md +21 -0
  10. package/spec/output-schemas/audit-report.schema.json +284 -0
  11. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  12. package/src/cli/commands/audit.ts +231 -0
  13. package/src/cli/commands/publish.ts +43 -7
  14. package/src/cli/errors.ts +9 -2
  15. package/src/cli/program.ts +112 -0
  16. package/src/core/audit-contract.ts +296 -0
  17. package/src/core/audit-freshness.ts +233 -0
  18. package/src/core/audit-links.ts +222 -0
  19. package/src/core/audit-provenance.ts +154 -0
  20. package/src/core/audit-report.ts +318 -0
  21. package/src/core/audit-workspace.ts +678 -0
  22. package/src/core/audit.ts +569 -0
  23. package/src/core/capture.ts +196 -3
  24. package/src/core/document-capabilities.ts +9 -8
  25. package/src/core/record-metadata.ts +33 -0
  26. package/src/ingestion/strip.ts +152 -26
  27. package/src/mcp/http-egress.ts +8 -0
  28. package/src/mcp/tools/audit.ts +97 -0
  29. package/src/mcp/tools/index.ts +13 -0
  30. package/src/publish/artifact-asset-codec.ts +75 -0
  31. package/src/publish/artifact-asset-contract.ts +152 -0
  32. package/src/publish/artifact-asset-parse.ts +401 -0
  33. package/src/publish/artifact-asset-sniff.ts +108 -0
  34. package/src/publish/artifact-asset-validate.ts +209 -0
  35. package/src/publish/artifact-assets.ts +58 -0
  36. package/src/publish/artifact-validation.ts +32 -6
  37. package/src/publish/artifact.ts +50 -3
  38. package/src/publish/attachment-bundle.ts +145 -0
  39. package/src/publish/attachment-discover.ts +203 -0
  40. package/src/publish/attachment-load.ts +133 -0
  41. package/src/publish/attachment-obsidian.ts +45 -0
  42. package/src/publish/attachment-path.ts +334 -0
  43. package/src/publish/attachment-raster.ts +852 -0
  44. package/src/publish/attachment-resolver.ts +280 -0
  45. package/src/publish/attachment-types.ts +54 -0
  46. package/src/publish/encrypted-export.ts +121 -44
  47. package/src/publish/export-attachments.ts +224 -0
  48. package/src/publish/export-service.ts +142 -80
  49. package/src/publish/obsidian-sanitize.ts +121 -13
  50. package/src/serve/routes/api.ts +2 -1
  51. package/src/store/sqlite/adapter.ts +82 -0
  52. package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
  53. package/src/store/sqlite/graph-link-resolver.ts +241 -2
  54. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
@@ -0,0 +1,203 @@
1
+ /** CommonMark + Obsidian image discovery for publish attachments. */
2
+
3
+ import { fromMarkdown } from "mdast-util-from-markdown";
4
+ import { gfmFromMarkdown } from "mdast-util-gfm";
5
+ import { gfm } from "micromark-extension-gfm";
6
+
7
+ import {
8
+ type ExcludedRange,
9
+ getExcludedRanges,
10
+ rangeIntersectsExcluded,
11
+ } from "../ingestion/strip";
12
+ import { parseObsidianEmbedAt } from "./attachment-obsidian";
13
+
14
+ export interface DiscoveredImageRef {
15
+ alt: string;
16
+ end: number;
17
+ kind: "markdown" | "obsidian";
18
+ sourceRef: string;
19
+ start: number;
20
+ title?: string | null;
21
+ }
22
+
23
+ export interface DiscoverImageOptions {
24
+ excludeFrontmatter?: boolean;
25
+ }
26
+
27
+ interface PositionedNode {
28
+ alt?: string | null;
29
+ children?: PositionedNode[];
30
+ identifier?: string;
31
+ position?: {
32
+ end: { offset?: number };
33
+ start: { offset?: number };
34
+ };
35
+ type: string;
36
+ title?: string | null;
37
+ url?: string;
38
+ }
39
+
40
+ interface MarkdownScan {
41
+ definitions: Map<string, { title: string | null; url: string }>;
42
+ excluded: ExcludedRange[];
43
+ images: DiscoveredImageRef[];
44
+ references: PositionedNode[];
45
+ }
46
+
47
+ const nodeOffsets = (
48
+ node: PositionedNode
49
+ ): { end: number; start: number } | null => {
50
+ const start = node.position?.start.offset;
51
+ const end = node.position?.end.offset;
52
+ return start === undefined || end === undefined ? null : { start, end };
53
+ };
54
+
55
+ const markdownImage = (
56
+ node: PositionedNode,
57
+ sourceRef: string,
58
+ title: string | null
59
+ ): DiscoveredImageRef | null => {
60
+ const offsets = nodeOffsets(node);
61
+ if (!offsets) return null;
62
+ return {
63
+ alt: node.alt ?? "",
64
+ end: offsets.end,
65
+ kind: "markdown",
66
+ sourceRef,
67
+ start: offsets.start,
68
+ title,
69
+ };
70
+ };
71
+
72
+ const scanMarkdownAst = (markdown: string): MarkdownScan => {
73
+ const root = fromMarkdown(markdown, {
74
+ extensions: [gfm()],
75
+ mdastExtensions: [gfmFromMarkdown()],
76
+ }) as PositionedNode;
77
+ const scan: MarkdownScan = {
78
+ definitions: new Map(),
79
+ excluded: [],
80
+ images: [],
81
+ references: [],
82
+ };
83
+
84
+ const visit = (node: PositionedNode): void => {
85
+ const offsets = nodeOffsets(node);
86
+ if (
87
+ offsets &&
88
+ (node.type === "code" ||
89
+ node.type === "inlineCode" ||
90
+ node.type === "html" ||
91
+ node.type === "definition")
92
+ ) {
93
+ scan.excluded.push({ ...offsets, kind: "inline_code" });
94
+ }
95
+ if (offsets && node.type === "link") {
96
+ const isAutolink =
97
+ markdown[offsets.start] === "<" && markdown[offsets.end - 1] === ">";
98
+ const childEnds = (node.children ?? [])
99
+ .map((child) => nodeOffsets(child)?.end)
100
+ .filter((offset): offset is number => offset !== undefined);
101
+ scan.excluded.push({
102
+ start: isAutolink
103
+ ? offsets.start
104
+ : childEnds.length === 0
105
+ ? offsets.start
106
+ : Math.max(...childEnds),
107
+ end: offsets.end,
108
+ kind: "inline_code",
109
+ });
110
+ }
111
+ if (
112
+ node.type === "definition" &&
113
+ node.identifier !== undefined &&
114
+ node.url !== undefined &&
115
+ !scan.definitions.has(node.identifier)
116
+ ) {
117
+ scan.definitions.set(node.identifier, {
118
+ title: node.title ?? null,
119
+ url: node.url,
120
+ });
121
+ } else if (node.type === "image" && node.url !== undefined) {
122
+ const image = markdownImage(node, node.url, node.title ?? null);
123
+ if (image) scan.images.push(image);
124
+ } else if (node.type === "imageReference") {
125
+ scan.references.push(node);
126
+ }
127
+ for (const child of node.children ?? []) visit(child);
128
+ };
129
+ visit(root);
130
+
131
+ for (const reference of scan.references) {
132
+ const definition = scan.definitions.get(reference.identifier ?? "");
133
+ if (definition === undefined) continue;
134
+ const image = markdownImage(reference, definition.url, definition.title);
135
+ if (image) scan.images.push(image);
136
+ }
137
+ return scan;
138
+ };
139
+
140
+ const isEscapedMarker = (text: string, index: number): boolean => {
141
+ let backslashes = 0;
142
+ for (
143
+ let cursor = index - 1;
144
+ cursor >= 0 && text[cursor] === "\\";
145
+ cursor -= 1
146
+ ) {
147
+ backslashes += 1;
148
+ }
149
+ return backslashes % 2 === 1;
150
+ };
151
+
152
+ const discoverObsidianImages = (
153
+ markdown: string,
154
+ excluded: ExcludedRange[]
155
+ ): DiscoveredImageRef[] => {
156
+ const images: DiscoveredImageRef[] = [];
157
+ let cursor = 0;
158
+ while (cursor < markdown.length) {
159
+ const bang = markdown.indexOf("![[", cursor);
160
+ if (bang < 0) break;
161
+ const parsed = isEscapedMarker(markdown, bang)
162
+ ? null
163
+ : parseObsidianEmbedAt(markdown, bang);
164
+ if (
165
+ parsed &&
166
+ !rangeIntersectsExcluded(parsed.start, parsed.end, excluded)
167
+ ) {
168
+ images.push(parsed);
169
+ }
170
+ cursor = parsed?.end ?? bang + 1;
171
+ }
172
+ return images;
173
+ };
174
+
175
+ export const discoverImageOccurrences = (
176
+ markdown: string,
177
+ options: DiscoverImageOptions = {}
178
+ ): DiscoveredImageRef[] => {
179
+ const scan = scanMarkdownAst(markdown);
180
+ const frontmatter =
181
+ options.excludeFrontmatter === false
182
+ ? []
183
+ : getExcludedRanges(markdown).filter(
184
+ (range) => range.kind === "frontmatter"
185
+ );
186
+ const markdownImageRanges: ExcludedRange[] = scan.images.map((image) => ({
187
+ start: image.start,
188
+ end: image.end,
189
+ kind: "inline_code",
190
+ }));
191
+ const excluded = [
192
+ ...scan.excluded,
193
+ ...frontmatter,
194
+ ...markdownImageRanges,
195
+ ].sort((left, right) => left.start - right.start);
196
+ const markdownImages = scan.images.filter(
197
+ (image) => !rangeIntersectsExcluded(image.start, image.end, frontmatter)
198
+ );
199
+ return [
200
+ ...markdownImages,
201
+ ...discoverObsidianImages(markdown, excluded),
202
+ ].sort((left, right) => left.start - right.start || left.end - right.end);
203
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Load and validate attachment bytes into pending publish asset payloads.
3
+ *
4
+ * @module src/publish/attachment-load
5
+ */
6
+
7
+ import type {
8
+ AttachmentDiagnostic,
9
+ PendingAssetPayload,
10
+ } from "./attachment-types";
11
+
12
+ import { encodeBytesToBase64, sha256BytesHex } from "./artifact-asset-codec";
13
+ import { MAX_PUBLISH_UPLOAD_BYTES } from "./artifact-asset-contract";
14
+ import { diagnostic, extensionOf } from "./attachment-path";
15
+ import {
16
+ RASTER_HEADER_PROBE_BYTES,
17
+ validateRasterBytesStructural,
18
+ validateRasterDecodable,
19
+ } from "./attachment-raster";
20
+
21
+ const SUPPORTED_EXT = new Set([
22
+ ".png",
23
+ ".jpg",
24
+ ".jpeg",
25
+ ".gif",
26
+ ".webp",
27
+ ".avif",
28
+ ]);
29
+ const UNSUPPORTED_EXT = new Set([".svg", ".pdf", ".html", ".htm"]);
30
+
31
+ export async function readAndValidateAsset(
32
+ absPath: string,
33
+ noteSlug: string,
34
+ sourceRef: string,
35
+ relPath: string
36
+ ): Promise<PendingAssetPayload | AttachmentDiagnostic> {
37
+ const ext = extensionOf(relPath);
38
+ if (UNSUPPORTED_EXT.has(ext)) {
39
+ return diagnostic(
40
+ "ASSET_UNSUPPORTED_FORMAT",
41
+ `Unsupported attachment format "${ext}"`,
42
+ noteSlug,
43
+ sourceRef
44
+ );
45
+ }
46
+ const file = Bun.file(absPath);
47
+ if (!(await file.exists())) {
48
+ return diagnostic(
49
+ "ASSET_MISSING",
50
+ `Attachment not found: ${relPath}`,
51
+ noteSlug,
52
+ sourceRef
53
+ );
54
+ }
55
+ const size = file.size;
56
+ if (size <= 0) {
57
+ return diagnostic(
58
+ "ASSET_CORRUPT",
59
+ "Attachment is empty",
60
+ noteSlug,
61
+ sourceRef
62
+ );
63
+ }
64
+ if (size > MAX_PUBLISH_UPLOAD_BYTES) {
65
+ return diagnostic(
66
+ "ASSET_OVERSIZE",
67
+ `Attachment is ${size} bytes before read; max is ${MAX_PUBLISH_UPLOAD_BYTES}`,
68
+ noteSlug,
69
+ sourceRef
70
+ );
71
+ }
72
+
73
+ const header = new Uint8Array(
74
+ await file.slice(0, Math.min(size, RASTER_HEADER_PROBE_BYTES)).arrayBuffer()
75
+ );
76
+ // Header probe uses structural checks only (sync, cheap reject).
77
+ const headerCheck = validateRasterBytesStructural(header);
78
+ if (!headerCheck.ok) {
79
+ const rejectEarly =
80
+ headerCheck.code === "ASSET_DIMENSION_INVALID" ||
81
+ headerCheck.code === "ASSET_UNSUPPORTED_FORMAT" ||
82
+ (headerCheck.code === "ASSET_CORRUPT" &&
83
+ size <= RASTER_HEADER_PROBE_BYTES);
84
+ if (rejectEarly) {
85
+ if (
86
+ headerCheck.code === "ASSET_UNSUPPORTED_FORMAT" &&
87
+ SUPPORTED_EXT.has(ext)
88
+ ) {
89
+ return diagnostic(
90
+ "ASSET_MIME_SPOOF",
91
+ `Extension ${ext} does not match raster bytes`,
92
+ noteSlug,
93
+ sourceRef
94
+ );
95
+ }
96
+ return diagnostic(
97
+ headerCheck.code,
98
+ headerCheck.message,
99
+ noteSlug,
100
+ sourceRef
101
+ );
102
+ }
103
+ }
104
+
105
+ const bytes = new Uint8Array(await file.arrayBuffer());
106
+ // Full producer path: structural validation plus pixel decodability before bundling.
107
+ const validated = await validateRasterDecodable(bytes);
108
+ if (!validated.ok) {
109
+ if (
110
+ validated.code === "ASSET_UNSUPPORTED_FORMAT" &&
111
+ SUPPORTED_EXT.has(ext)
112
+ ) {
113
+ return diagnostic(
114
+ "ASSET_MIME_SPOOF",
115
+ `Extension ${ext} does not match raster bytes`,
116
+ noteSlug,
117
+ sourceRef
118
+ );
119
+ }
120
+ return diagnostic(validated.code, validated.message, noteSlug, sourceRef);
121
+ }
122
+
123
+ const sha256 = sha256BytesHex(bytes);
124
+ return {
125
+ byteLength: bytes.byteLength,
126
+ data: encodeBytesToBase64(bytes),
127
+ height: validated.height,
128
+ mediaType: validated.mediaType,
129
+ references: [{ noteSlug, sourceRef: relPath }],
130
+ sha256,
131
+ width: validated.width,
132
+ };
133
+ }
@@ -0,0 +1,45 @@
1
+ /** Obsidian image-embed parsing for publish attachment discovery. */
2
+
3
+ import type { DiscoveredImageRef } from "./attachment-discover";
4
+
5
+ const parseObsidianTarget = (
6
+ raw: string
7
+ ): { alias: string; pathPart: string } => {
8
+ // Markdown tables require Obsidian's separator pipes to be escaped. Restore
9
+ // those escapes before applying Obsidian's target|alias grammar.
10
+ const normalized = raw.replaceAll("\\|", "|");
11
+ const pipe = normalized.indexOf("|");
12
+ const pathWithFrag = pipe >= 0 ? normalized.slice(0, pipe) : normalized;
13
+ const alias = pipe >= 0 ? normalized.slice(pipe + 1).trim() : "";
14
+ const hash = pathWithFrag.indexOf("#");
15
+ const pathPart = (
16
+ hash >= 0 ? pathWithFrag.slice(0, hash) : pathWithFrag
17
+ ).trim();
18
+ return { alias, pathPart };
19
+ };
20
+
21
+ export const parseObsidianEmbedAt = (
22
+ text: string,
23
+ bangIndex: number
24
+ ): DiscoveredImageRef | null => {
25
+ if (text.slice(bangIndex, bangIndex + 3) !== "![[") return null;
26
+ let i = bangIndex + 3;
27
+ let raw = "";
28
+ while (i < text.length) {
29
+ const ch = text[i] ?? "";
30
+ if (ch === "]" && text[i + 1] === "]") {
31
+ const parsed = parseObsidianTarget(raw);
32
+ return {
33
+ alt: parsed.alias,
34
+ end: i + 2,
35
+ kind: "obsidian",
36
+ sourceRef: parsed.pathPart,
37
+ start: bangIndex,
38
+ };
39
+ }
40
+ if (ch === "\n" || ch === "\r") return null;
41
+ raw += ch;
42
+ i += 1;
43
+ }
44
+ return null;
45
+ };
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Collection-root path confinement and basename lookup for publish attachments.
3
+ *
4
+ * @module src/publish/attachment-path
5
+ */
6
+
7
+ // node:fs/promises realpath/lstat/opendir — no Bun equivalents for symlink-safe identity or prunable directory walking
8
+ import { lstat, opendir, realpath } from "node:fs/promises";
9
+ // node:path — no Bun path utils
10
+ import {
11
+ isAbsolute,
12
+ join,
13
+ normalize,
14
+ posix as pathPosix,
15
+ relative,
16
+ sep,
17
+ } from "node:path";
18
+
19
+ import type { AttachmentDiagnostic } from "./attachment-types";
20
+
21
+ import { matchesCollectionExclusion } from "../core/path-rules";
22
+ import { isCanonicalPathContained } from "../core/validation";
23
+
24
+ const compareCodeUnits = (left: string, right: string): number => {
25
+ if (left < right) return -1;
26
+ if (left > right) return 1;
27
+ return 0;
28
+ };
29
+
30
+ const isPrivateAttachmentRelPath = (relPath: string): boolean =>
31
+ relPath.split("/").some((segment) => segment.toLowerCase() === "_internal");
32
+
33
+ export const diagnostic = (
34
+ code: AttachmentDiagnostic["code"],
35
+ message: string,
36
+ noteSlug: string,
37
+ sourceRef: string
38
+ ): AttachmentDiagnostic => ({ code, message, noteSlug, sourceRef });
39
+
40
+ export const safePercentDecode = (value: string): string | null => {
41
+ let current = value;
42
+ for (let round = 0; round < 4; round += 1) {
43
+ if (!/%[0-9a-fA-F]{2}/u.test(current)) return current;
44
+ try {
45
+ const decoded = decodeURIComponent(current);
46
+ if (decoded === current) return current;
47
+ if (decoded.includes("\0")) return null;
48
+ current = decoded;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ return current;
54
+ };
55
+
56
+ const percentDecodeOnce = (value: string): string | null => {
57
+ if (!/%[0-9a-fA-F]{2}/u.test(value)) return value;
58
+ try {
59
+ const decoded = decodeURIComponent(value);
60
+ return decoded.includes("\0") ? null : decoded;
61
+ } catch {
62
+ return null;
63
+ }
64
+ };
65
+
66
+ export const extensionOf = (relPath: string): string => {
67
+ const base = pathPosix.basename(relPath);
68
+ const dot = base.lastIndexOf(".");
69
+ if (dot <= 0) return "";
70
+ return base.slice(dot).toLowerCase();
71
+ };
72
+
73
+ /**
74
+ * Build basename → relPath[] index for files under root.
75
+ *
76
+ * Unsupported files must remain discoverable so the resolver can report a
77
+ * stable unsupported-format diagnostic instead of misclassifying them as
78
+ * missing. Byte validation still decides whether any match may be bundled.
79
+ */
80
+ export async function buildAttachmentBasenameIndex(
81
+ collectionRoot: string,
82
+ collectionExcludes: readonly string[] = []
83
+ ): Promise<Map<string, string[]>> {
84
+ let rootReal: string;
85
+ try {
86
+ rootReal = await realpath(normalize(collectionRoot));
87
+ } catch {
88
+ return new Map();
89
+ }
90
+ const index = new Map<string, string[]>();
91
+ const walk = async (absDir: string, relDir: string): Promise<void> => {
92
+ let directory: Awaited<ReturnType<typeof opendir>>;
93
+ try {
94
+ directory = await opendir(absDir);
95
+ } catch {
96
+ return;
97
+ }
98
+ for await (const entry of directory) {
99
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
100
+ const excluded =
101
+ matchesCollectionExclusion(rel, collectionExcludes) ||
102
+ (entry.isDirectory() &&
103
+ matchesCollectionExclusion(`${rel}/`, collectionExcludes));
104
+ if (isPrivateAttachmentRelPath(rel) || excluded) {
105
+ continue;
106
+ }
107
+ if (entry.isDirectory()) {
108
+ await walk(join(absDir, entry.name), rel);
109
+ continue;
110
+ }
111
+ if (!entry.isFile()) continue;
112
+ const base = pathPosix.basename(rel);
113
+ const bucket = index.get(base) ?? [];
114
+ bucket.push(rel);
115
+ index.set(base, bucket);
116
+ }
117
+ };
118
+ await walk(rootReal, "");
119
+ for (const [key, values] of index) {
120
+ index.set(key, [...new Set(values)].sort(compareCodeUnits));
121
+ }
122
+ return index;
123
+ }
124
+
125
+ export interface AttachmentPathContext {
126
+ basenameIndex: Map<string, string[]>;
127
+ collectionExcludes?: readonly string[];
128
+ noteSlug: string;
129
+ sourceRelPath: string;
130
+ }
131
+
132
+ const stripMarkdownUrlSuffix = (sourceRef: string): string => {
133
+ const fragmentIndex = sourceRef.indexOf("#");
134
+ const queryIndex = sourceRef.indexOf("?");
135
+ const suffixIndexes = [fragmentIndex, queryIndex].filter(
136
+ (index) => index >= 0
137
+ );
138
+ if (suffixIndexes.length === 0) return sourceRef;
139
+ return sourceRef.slice(0, Math.min(...suffixIndexes));
140
+ };
141
+
142
+ export const resolveCandidateRelPath = (
143
+ sourceRef: string,
144
+ ctx: AttachmentPathContext,
145
+ kind: "markdown" | "obsidian"
146
+ ):
147
+ | { ok: true; relPath: string }
148
+ | { ok: false; diagnostic: AttachmentDiagnostic } => {
149
+ // Markdown destinations follow URL semantics: raw query/fragment suffixes
150
+ // are not part of the local filename. Split before percent-decoding so an
151
+ // encoded literal `%23` or `%3F` can still address a real filename.
152
+ const pathSourceRef =
153
+ kind === "markdown" ? stripMarkdownUrlSuffix(sourceRef) : sourceRef;
154
+ const decoded = percentDecodeOnce(pathSourceRef);
155
+ const securityDecoded = safePercentDecode(pathSourceRef);
156
+ if (decoded === null || securityDecoded === null || decoded.trim() === "") {
157
+ return {
158
+ ok: false,
159
+ diagnostic: diagnostic(
160
+ "ASSET_CORRUPT",
161
+ "Malformed image reference encoding",
162
+ ctx.noteSlug,
163
+ sourceRef
164
+ ),
165
+ };
166
+ }
167
+ if (
168
+ decoded.includes("\0") ||
169
+ decoded.includes("\\") ||
170
+ securityDecoded.includes("\\")
171
+ ) {
172
+ return {
173
+ ok: false,
174
+ diagnostic: diagnostic(
175
+ "ASSET_TRAVERSAL",
176
+ "Image reference escapes the approved collection root",
177
+ ctx.noteSlug,
178
+ sourceRef
179
+ ),
180
+ };
181
+ }
182
+ if (
183
+ isAbsolute(decoded) ||
184
+ pathPosix.isAbsolute(decoded) ||
185
+ isAbsolute(securityDecoded) ||
186
+ pathPosix.isAbsolute(securityDecoded)
187
+ ) {
188
+ return {
189
+ ok: false,
190
+ diagnostic: diagnostic(
191
+ "ASSET_TRAVERSAL",
192
+ "Absolute image paths are not allowed",
193
+ ctx.noteSlug,
194
+ sourceRef
195
+ ),
196
+ };
197
+ }
198
+
199
+ const hasSlash = decoded.includes("/");
200
+ if (!hasSlash && kind === "obsidian") {
201
+ const matches = ctx.basenameIndex.get(pathPosix.basename(decoded)) ?? [];
202
+ if (matches.length === 0) {
203
+ return {
204
+ ok: false,
205
+ diagnostic: diagnostic(
206
+ "ASSET_MISSING",
207
+ `No file named "${pathPosix.basename(decoded)}" under collection root`,
208
+ ctx.noteSlug,
209
+ sourceRef
210
+ ),
211
+ };
212
+ }
213
+ if (matches.length > 1) {
214
+ return {
215
+ ok: false,
216
+ diagnostic: diagnostic(
217
+ "ASSET_AMBIGUOUS",
218
+ `Ambiguous basename "${pathPosix.basename(decoded)}" matches ${matches.length} files`,
219
+ ctx.noteSlug,
220
+ sourceRef
221
+ ),
222
+ };
223
+ }
224
+ return { ok: true, relPath: matches[0]! };
225
+ }
226
+
227
+ const baseDir =
228
+ kind === "obsidian" && hasSlash ? "" : pathPosix.dirname(ctx.sourceRelPath);
229
+ const joined = pathPosix.normalize(
230
+ pathPosix.join(baseDir === "." ? "" : baseDir, decoded)
231
+ );
232
+ const securityJoined = pathPosix.normalize(
233
+ pathPosix.join(baseDir === "." ? "" : baseDir, securityDecoded)
234
+ );
235
+ if (
236
+ joined.startsWith("..") ||
237
+ joined.split("/").includes("..") ||
238
+ pathPosix.isAbsolute(joined) ||
239
+ securityJoined.startsWith("..") ||
240
+ securityJoined.split("/").includes("..") ||
241
+ pathPosix.isAbsolute(securityJoined)
242
+ ) {
243
+ return {
244
+ ok: false,
245
+ diagnostic: diagnostic(
246
+ "ASSET_TRAVERSAL",
247
+ "Image reference escapes the approved collection root",
248
+ ctx.noteSlug,
249
+ sourceRef
250
+ ),
251
+ };
252
+ }
253
+ if (
254
+ isPrivateAttachmentRelPath(joined) ||
255
+ matchesCollectionExclusion(joined, ctx.collectionExcludes ?? [])
256
+ ) {
257
+ return {
258
+ ok: false,
259
+ diagnostic: diagnostic(
260
+ "ASSET_MISSING",
261
+ "Attachment not found",
262
+ ctx.noteSlug,
263
+ sourceRef
264
+ ),
265
+ };
266
+ }
267
+ return { ok: true, relPath: joined.replace(/^\.\//u, "") };
268
+ };
269
+
270
+ export async function assertContainedFile(
271
+ collectionRoot: string,
272
+ relPath: string,
273
+ noteSlug: string,
274
+ sourceRef: string,
275
+ collectionExcludes: readonly string[] = []
276
+ ): Promise<{ absPath: string } | AttachmentDiagnostic> {
277
+ const rootReal = await realpath(normalize(collectionRoot));
278
+ const absLexical = normalize(join(rootReal, ...relPath.split("/")));
279
+ if (!isCanonicalPathContained(rootReal, absLexical)) {
280
+ throw new Error(
281
+ `ASSET_TRAVERSAL: image path escapes collection root (${sourceRef})`
282
+ );
283
+ }
284
+ let info: Awaited<ReturnType<typeof lstat>>;
285
+ try {
286
+ info = await lstat(absLexical);
287
+ } catch {
288
+ return diagnostic(
289
+ "ASSET_MISSING",
290
+ `Attachment not found: ${relPath}`,
291
+ noteSlug,
292
+ sourceRef
293
+ );
294
+ }
295
+ if (info.isSymbolicLink()) {
296
+ throw new Error(
297
+ `ASSET_TRAVERSAL: refusing symlink attachment (${sourceRef})`
298
+ );
299
+ }
300
+ if (!info.isFile()) {
301
+ return diagnostic(
302
+ "ASSET_MISSING",
303
+ `Attachment is not a regular file: ${relPath}`,
304
+ noteSlug,
305
+ sourceRef
306
+ );
307
+ }
308
+ let absReal: string;
309
+ try {
310
+ absReal = await realpath(absLexical);
311
+ } catch {
312
+ throw new Error(
313
+ `ASSET_TRAVERSAL: unable to realpath attachment (${sourceRef})`
314
+ );
315
+ }
316
+ if (!isCanonicalPathContained(rootReal, absReal)) {
317
+ throw new Error(
318
+ `ASSET_TRAVERSAL: symlink escape outside collection root (${sourceRef})`
319
+ );
320
+ }
321
+ const canonicalRelPath = relative(rootReal, absReal).split(sep).join("/");
322
+ if (
323
+ isPrivateAttachmentRelPath(canonicalRelPath) ||
324
+ matchesCollectionExclusion(canonicalRelPath, collectionExcludes)
325
+ ) {
326
+ return diagnostic(
327
+ "ASSET_MISSING",
328
+ "Attachment not found",
329
+ noteSlug,
330
+ sourceRef
331
+ );
332
+ }
333
+ return { absPath: absReal };
334
+ }