@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.
- package/README.md +17 -3
- package/assets/skill/SKILL.md +30 -0
- package/assets/skill/cli-reference.md +10 -2
- package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.34.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.34.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +5 -1
- package/spec/cli.md +60 -1
- package/spec/mcp.md +21 -0
- package/spec/output-schemas/audit-report.schema.json +284 -0
- package/spec/output-schemas/publish-artifact.schema.json +76 -1
- package/src/cli/commands/audit.ts +231 -0
- package/src/cli/commands/publish.ts +43 -7
- package/src/cli/errors.ts +9 -2
- package/src/cli/program.ts +112 -0
- package/src/core/audit-contract.ts +296 -0
- package/src/core/audit-freshness.ts +233 -0
- package/src/core/audit-links.ts +222 -0
- package/src/core/audit-provenance.ts +154 -0
- package/src/core/audit-report.ts +318 -0
- package/src/core/audit-workspace.ts +678 -0
- package/src/core/audit.ts +569 -0
- package/src/core/capture.ts +196 -3
- package/src/core/document-capabilities.ts +9 -8
- package/src/core/record-metadata.ts +33 -0
- package/src/ingestion/strip.ts +152 -26
- package/src/mcp/http-egress.ts +8 -0
- package/src/mcp/tools/audit.ts +97 -0
- package/src/mcp/tools/index.ts +13 -0
- package/src/publish/artifact-asset-codec.ts +75 -0
- package/src/publish/artifact-asset-contract.ts +152 -0
- package/src/publish/artifact-asset-parse.ts +401 -0
- package/src/publish/artifact-asset-sniff.ts +108 -0
- package/src/publish/artifact-asset-validate.ts +209 -0
- package/src/publish/artifact-assets.ts +58 -0
- package/src/publish/artifact-validation.ts +32 -6
- package/src/publish/artifact.ts +50 -3
- package/src/publish/attachment-bundle.ts +145 -0
- package/src/publish/attachment-discover.ts +203 -0
- package/src/publish/attachment-load.ts +133 -0
- package/src/publish/attachment-obsidian.ts +45 -0
- package/src/publish/attachment-path.ts +334 -0
- package/src/publish/attachment-raster.ts +852 -0
- package/src/publish/attachment-resolver.ts +280 -0
- package/src/publish/attachment-types.ts +54 -0
- package/src/publish/encrypted-export.ts +121 -44
- package/src/publish/export-attachments.ts +224 -0
- package/src/publish/export-service.ts +142 -80
- package/src/publish/obsidian-sanitize.ts +121 -13
- package/src/serve/routes/api.ts +2 -1
- package/src/store/sqlite/adapter.ts +82 -0
- package/src/store/sqlite/graph-link-bulk-resolver.ts +191 -0
- package/src/store/sqlite/graph-link-resolver.ts +241 -2
- 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
|
+
}
|