返回源码地图

packages/attachment/attachment-local/src/file-store.ts

main snapshot · da00f7f5358f · 正文引用章节 15;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/** Verbatim content-addressed local file storage. @module @deepseek-ai/dsh-attachment-local/file-store */
2
3import { createHash } from 'node:crypto'
4import { createReadStream } from 'node:fs'
5import { join } from 'node:path'
6import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
7import type {
8 FileAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,
9} from '@deepseek-ai/dsh-attachment'
10import {
11 publishImmutableAlias, publishImmutableObject, publishImmutableObjectStream,
12} from './store.ts'
13
14const FILE_ID_PATTERN = /^sha256:([a-f0-9]{64})$/
15const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu
16
17function isWindowsDeviceName(name: string): boolean {
18 const dot = name.indexOf('.')
19 const stem = (dot < 0 ? name : name.slice(0, dot)).replace(/[. ]+$/u, '')
20 return WINDOWS_DEVICE_NAME.test(stem)
21}
22
23function utf8Prefix(value: string, maxBytes: number): string {
24 let bytes = 0
25 let prefix = ''
26 for (const character of Buffer.from(value).toString('utf8')) {
27 const characterBytes = Buffer.byteLength(character)
28 if (bytes + characterBytes > maxBytes) break
29 prefix += character
30 bytes += characterBytes
31 }
32 return prefix
33}
34
35/**
36 * Sanitize one caller display name into a safe stored leaf name. Both
37 * separator styles are stripped by hand: a POSIX host treats `\` as an
38 * ordinary character, so path.basename would keep a Windows client's full
39 * local path and leak it into the reference and the session log. Characters
40 * Windows refuses in file names become `_` so one reference stays valid on
41 * every supported host.
42 * @param value - caller-declared display name, possibly a full client path.
43 * @returns a non-empty leaf name safe to store on every supported filesystem.
44 */
45export function fileLeafName(value: string | undefined): string {
46 if (value === undefined) return 'file'
47 const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1)
48 let clean = leaf
49 .replace(/[\u0000-\u001f\u007f]/g, '')
50 .replace(/[<>:"|?*]/g, '_')
51 .trim()
52 .replace(/[. ]+$/u, '')
53 if (isWindowsDeviceName(clean)) clean = `_${clean}`
54 clean = utf8Prefix(clean, 255).replace(/[. ]+$/u, '')
55 return clean === '' || clean === '.' || clean === '..' ? 'file' : clean
56}
57
58function ensureFileReference(ref: FileAttachmentRef): string {
59 const match = FILE_ID_PATTERN.exec(String(ref.attachmentId))
60 if (match?.[1] === undefined || ref.name !== fileLeafName(ref.name)) {
61 throw new AttachmentError('File attachment reference is invalid.', 'INVALID_ATTACHMENT_REF')
62 }
63 return match[1]
64}
65
66/**
67 * Derive the absolute immutable-object path for one stored file. The digest
68 * names a directory so the sanitized display name stays the stored leaf name,
69 * giving models and users a path that ends in the real filename.
70 * @param root - absolute `DSH_HOME/attachments/v1` root.
71 * @param ref - durable file reference from the session log or an upload receipt.
72 * @returns provider-local path without reading the object.
73 * @throws an AttachmentError when the reference digest or name is invalid.
74 */
75export function storedFilePath(root: string, ref: FileAttachmentRef): string {
76 const sha256 = ensureFileReference(ref)
77 return join(root, 'files', sha256.slice(0, 2), sha256, ref.name)
78}
79
80/** Canonical object path shared by every display name for one digest. */
81function storedFileObjectPath(root: string, sha256: string): string {
82 return join(root, 'file-objects', sha256.slice(0, 2), sha256)
83}
84
85/**
86 * Commit one file byte-for-byte below a versioned attachment root.
87 * @param root - absolute `DSH_HOME/attachments/v1` root.
88 * @param input - exact bytes and optional display name.
89 * @returns the durable content-addressed file reference.
90 */
91export async function saveFileVerbatim(
92 root: string,
93 input: SaveFileAttachment,
94): Promise<FileAttachmentRef> {
95 const sha256 = createHash('sha256').update(input.data).digest('hex')
96 const ref: FileAttachmentRef = {
97 attachmentId: AttachmentId(`sha256:${sha256}`),
98 name: fileLeafName(input.name),
99 bytes: input.data.byteLength,
100 }
101 const objectPath = storedFileObjectPath(root, sha256)
102 await publishImmutableObject(root, objectPath, input.data, sha256)
103 await publishImmutableAlias(root, objectPath, storedFilePath(root, ref), sha256)
104 return ref
105}
106
107/**
108 * Commit one file byte-for-byte from bounded chunks below a versioned attachment root.
109 * @param root - absolute `DSH_HOME/attachments/v1` root.
110 * @param input - ordered exact bytes, optional cancellation, and display name.
111 * @returns the durable content-addressed file reference.
112 */
113export async function saveFileStreamVerbatim(
114 root: string,
115 input: SaveFileStreamAttachment,
116): Promise<FileAttachmentRef> {
117 const name = fileLeafName(input.name)
118 const stored = await publishImmutableObjectStream(
119 root,
120 input.data,
121 sha256 => storedFileObjectPath(root, sha256),
122 input.signal,
123 )
124 const ref: FileAttachmentRef = {
125 attachmentId: AttachmentId(`sha256:${stored.sha256}`),
126 name,
127 bytes: stored.bytes,
128 }
129 input.signal?.throwIfAborted()
130 await publishImmutableAlias(
131 root,
132 storedFileObjectPath(root, stored.sha256),
133 storedFilePath(root, ref),
134 stored.sha256,
135 )
136 input.signal?.throwIfAborted()
137 return ref
138}
139
140/**
141 * Read one stored file in bounded chunks and verify its byte count and digest.
142 * @param root - absolute `DSH_HOME/attachments/v1` root.
143 * @param ref - durable file reference from the session log.
144 * @param signal - optional cancellation for filesystem reads.
145 * @returns exact stored bytes in order; integrity failures reject after the final chunk.
146 */
147export async function* readFileStreamVerbatim(
148 root: string,
149 ref: FileAttachmentRef,
150 signal?: AbortSignal,
151): AsyncIterable<Uint8Array> {
152 signal?.throwIfAborted()
153 const sha256 = ensureFileReference(ref)
154 const stream = createReadStream(storedFilePath(root, ref), {
155 highWaterMark: 1 << 16,
156 ...(signal === undefined ? {} : { signal }),
157 })
158 const hash = createHash('sha256')
159 let bytes = 0
160 try {
161 for await (const chunk of stream) {
162 signal?.throwIfAborted()
163 const data = chunk as Buffer
164 hash.update(data)
165 bytes += data.byteLength
166 yield data
167 }
168 } catch (error) {
169 signal?.throwIfAborted()
170 if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
171 throw new AttachmentError('File attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
172 }
173 throw new AttachmentError('Unable to read file attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
174 } finally {
175 stream.destroy()
176 }
177 signal?.throwIfAborted()
178 if (bytes !== ref.bytes || hash.digest('hex') !== sha256) {
179 throw new AttachmentError('Stored file attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
180 }
181}