@remnic/core 9.3.519 → 9.3.520
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/dist/access-schema.d.ts +34 -34
- package/dist/index.d.ts +18 -1
- package/dist/index.js +513 -175
- package/dist/index.js.map +1 -1
- package/dist/schemas.d.ts +64 -64
- package/dist/shared-context/manager.d.ts +2 -2
- package/dist/transfer/types.d.ts +12 -12
- package/package.json +1 -1
- package/src/binary-lifecycle/backend.ts +162 -14
- package/src/binary-lifecycle/manifest.ts +24 -12
- package/src/binary-lifecycle/pipeline.test.ts +565 -1
- package/src/binary-lifecycle/pipeline.ts +296 -54
- package/src/binary-lifecycle/types.ts +2 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* so swapping storage providers requires no pipeline changes.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import type { Stats } from "node:fs";
|
|
10
10
|
import fsp from "node:fs/promises";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import type { BinaryStorageBackendConfig } from "./types.js";
|
|
@@ -27,6 +27,8 @@ export interface BinaryStorageBackend {
|
|
|
27
27
|
exists(remotePath: string): Promise<boolean>;
|
|
28
28
|
/** Delete a file from the backend. */
|
|
29
29
|
delete(remotePath: string): Promise<void>;
|
|
30
|
+
/** Return the user-resolvable markdown target for a stored backend path. */
|
|
31
|
+
getRedirectTarget?(remotePath: string): string;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
// ---------------------------------------------------------------------------
|
|
@@ -45,10 +47,9 @@ export class FilesystemBackend implements BinaryStorageBackend {
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
private resolveRemotePath(remotePath: string): string {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const resolved = path.resolve(this.basePath, remotePath);
|
|
50
|
+
const resolved = path.isAbsolute(remotePath)
|
|
51
|
+
? path.resolve(remotePath)
|
|
52
|
+
: path.resolve(this.basePath, remotePath);
|
|
52
53
|
const relative = path.relative(this.basePath, resolved);
|
|
53
54
|
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
54
55
|
throw new Error(`FilesystemBackend remotePath escapes basePath: ${JSON.stringify(remotePath)}`);
|
|
@@ -56,26 +57,169 @@ export class FilesystemBackend implements BinaryStorageBackend {
|
|
|
56
57
|
return resolved;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
private isInsideBase(candidate: string, realBase: string): boolean {
|
|
61
|
+
const relative = path.relative(realBase, candidate);
|
|
62
|
+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async realBasePathIfExists(): Promise<string | null> {
|
|
66
|
+
try {
|
|
67
|
+
const stat = await fsp.lstat(this.basePath);
|
|
68
|
+
if (stat.isSymbolicLink()) {
|
|
69
|
+
throw new Error(`FilesystemBackend basePath must not be a symlink: ${this.basePath}`);
|
|
70
|
+
}
|
|
71
|
+
if (!stat.isDirectory()) {
|
|
72
|
+
throw new Error(`FilesystemBackend basePath must be a directory: ${this.basePath}`);
|
|
73
|
+
}
|
|
74
|
+
return await fsp.realpath(this.basePath);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async ensureBaseDirectory(): Promise<string> {
|
|
84
|
+
await fsp.mkdir(this.basePath, { recursive: true });
|
|
85
|
+
const realBase = await this.realBasePathIfExists();
|
|
86
|
+
if (realBase === null) {
|
|
87
|
+
throw new Error(`FilesystemBackend failed to create basePath: ${this.basePath}`);
|
|
88
|
+
}
|
|
89
|
+
return realBase;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async ensureSafeParentDirectory(dest: string): Promise<string> {
|
|
93
|
+
const realBase = await this.ensureBaseDirectory();
|
|
94
|
+
const destDir = path.dirname(dest);
|
|
95
|
+
const relativeDir = path.relative(this.basePath, destDir);
|
|
96
|
+
const segments = relativeDir === "" ? [] : relativeDir.split(path.sep);
|
|
97
|
+
let current = this.basePath;
|
|
98
|
+
|
|
99
|
+
for (const segment of segments) {
|
|
100
|
+
if (segment === "." || segment === "") continue;
|
|
101
|
+
current = path.join(current, segment);
|
|
102
|
+
try {
|
|
103
|
+
const stat = await fsp.lstat(current);
|
|
104
|
+
if (stat.isSymbolicLink()) {
|
|
105
|
+
throw new Error(`FilesystemBackend remotePath traverses symlink: ${current}`);
|
|
106
|
+
}
|
|
107
|
+
if (!stat.isDirectory()) {
|
|
108
|
+
throw new Error(`FilesystemBackend remotePath parent is not a directory: ${current}`);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
await fsp.mkdir(current);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const realParent = await fsp.realpath(destDir);
|
|
119
|
+
if (!this.isInsideBase(realParent, realBase)) {
|
|
120
|
+
throw new Error(`FilesystemBackend remotePath parent escapes basePath: ${dest}`);
|
|
121
|
+
}
|
|
122
|
+
return realBase;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async resolveExistingRemotePath(remotePath: string): Promise<string | null> {
|
|
60
126
|
const dest = this.resolveRemotePath(remotePath);
|
|
127
|
+
const realBase = await this.realBasePathIfExists();
|
|
128
|
+
if (realBase === null) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
61
132
|
const destDir = path.dirname(dest);
|
|
62
|
-
|
|
63
|
-
|
|
133
|
+
const relativeDir = path.relative(this.basePath, destDir);
|
|
134
|
+
const segments = relativeDir === "" ? [] : relativeDir.split(path.sep);
|
|
135
|
+
let current = this.basePath;
|
|
136
|
+
for (const segment of segments) {
|
|
137
|
+
if (segment === "." || segment === "") continue;
|
|
138
|
+
current = path.join(current, segment);
|
|
139
|
+
let stat: Stats;
|
|
140
|
+
try {
|
|
141
|
+
stat = await fsp.lstat(current);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
if (stat.isSymbolicLink()) {
|
|
149
|
+
throw new Error(`FilesystemBackend remotePath traverses symlink: ${current}`);
|
|
150
|
+
}
|
|
151
|
+
if (!stat.isDirectory()) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const realParent = await fsp.realpath(destDir).catch((err: NodeJS.ErrnoException) => {
|
|
157
|
+
if (err.code === "ENOENT") return null;
|
|
158
|
+
throw err;
|
|
159
|
+
});
|
|
160
|
+
if (realParent === null) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (!this.isInsideBase(realParent, realBase)) {
|
|
164
|
+
throw new Error(`FilesystemBackend remotePath parent escapes basePath: ${JSON.stringify(remotePath)}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const stat = await fsp.lstat(dest);
|
|
169
|
+
if (stat.isSymbolicLink()) {
|
|
170
|
+
throw new Error(`FilesystemBackend remotePath points to symlink: ${dest}`);
|
|
171
|
+
}
|
|
172
|
+
if (!stat.isFile()) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const realDest = await fsp.realpath(dest);
|
|
183
|
+
if (!this.isInsideBase(realDest, realBase)) {
|
|
184
|
+
throw new Error(`FilesystemBackend remotePath escapes basePath: ${JSON.stringify(remotePath)}`);
|
|
185
|
+
}
|
|
64
186
|
return dest;
|
|
65
187
|
}
|
|
66
188
|
|
|
67
|
-
async
|
|
189
|
+
async upload(localPath: string, remotePath: string): Promise<string> {
|
|
190
|
+
if (path.isAbsolute(remotePath)) {
|
|
191
|
+
throw new Error(`FilesystemBackend upload remotePath must be relative: ${JSON.stringify(remotePath)}`);
|
|
192
|
+
}
|
|
68
193
|
const dest = this.resolveRemotePath(remotePath);
|
|
194
|
+
const realBase = await this.ensureSafeParentDirectory(dest);
|
|
69
195
|
try {
|
|
70
|
-
await fsp.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
196
|
+
const stat = await fsp.lstat(dest);
|
|
197
|
+
if (stat.isSymbolicLink()) {
|
|
198
|
+
throw new Error(`FilesystemBackend remotePath points to symlink: ${dest}`);
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
74
204
|
}
|
|
205
|
+
await fsp.copyFile(localPath, dest);
|
|
206
|
+
const realDest = await fsp.realpath(dest);
|
|
207
|
+
if (!this.isInsideBase(realDest, realBase)) {
|
|
208
|
+
throw new Error(`FilesystemBackend remotePath escapes basePath: ${JSON.stringify(remotePath)}`);
|
|
209
|
+
}
|
|
210
|
+
return remotePath;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async exists(remotePath: string): Promise<boolean> {
|
|
214
|
+
const dest = await this.resolveExistingRemotePath(remotePath);
|
|
215
|
+
return dest !== null;
|
|
75
216
|
}
|
|
76
217
|
|
|
77
218
|
async delete(remotePath: string): Promise<void> {
|
|
78
|
-
const dest = this.
|
|
219
|
+
const dest = await this.resolveExistingRemotePath(remotePath);
|
|
220
|
+
if (dest === null) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
79
223
|
try {
|
|
80
224
|
await fsp.unlink(dest);
|
|
81
225
|
} catch (err: unknown) {
|
|
@@ -83,6 +227,10 @@ export class FilesystemBackend implements BinaryStorageBackend {
|
|
|
83
227
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
84
228
|
}
|
|
85
229
|
}
|
|
230
|
+
|
|
231
|
+
getRedirectTarget(remotePath: string): string {
|
|
232
|
+
return this.resolveRemotePath(remotePath);
|
|
233
|
+
}
|
|
86
234
|
}
|
|
87
235
|
|
|
88
236
|
// ---------------------------------------------------------------------------
|
|
@@ -24,25 +24,37 @@ export function manifestPath(memoryDir: string): string {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Read the manifest from disk. Returns a fresh empty manifest if the file
|
|
27
|
-
* does not exist
|
|
27
|
+
* does not exist. Existing invalid manifests fail closed so the pipeline does
|
|
28
|
+
* not overwrite state needed for safe cleanup.
|
|
28
29
|
*/
|
|
29
30
|
export async function readManifest(memoryDir: string): Promise<BinaryLifecycleManifest> {
|
|
30
31
|
const filePath = manifestPath(memoryDir);
|
|
32
|
+
let raw: string;
|
|
31
33
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
34
|
+
raw = await fsp.readFile(filePath, "utf-8");
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
36
37
|
return emptyManifest();
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
throw new Error(`Failed to read binary lifecycle manifest at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let parsed: unknown;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(raw);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new Error(`Invalid binary lifecycle manifest JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// CLAUDE.md #18: validate the parsed result is a non-null object.
|
|
50
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
51
|
+
throw new Error(`Invalid binary lifecycle manifest shape at ${filePath}: expected object`);
|
|
52
|
+
}
|
|
53
|
+
const obj = parsed as Record<string, unknown>;
|
|
54
|
+
if (obj.version !== 1 || !Array.isArray(obj.assets)) {
|
|
55
|
+
throw new Error(`Invalid binary lifecycle manifest shape at ${filePath}: expected version 1 with assets array`);
|
|
45
56
|
}
|
|
57
|
+
return parsed as BinaryLifecycleManifest;
|
|
46
58
|
}
|
|
47
59
|
|
|
48
60
|
/**
|