1
/** Verbatim content-addressed local file storage. @module @deepseek-ai/dsh-attachment-local/file-store */3
import { createHash } from 'node:crypto'4
import { createReadStream } from 'node:fs'5
import { join } from 'node:path'6
import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'7
import type {8
FileAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,9
} from '@deepseek-ai/dsh-attachment'10
import {11
publishImmutableAlias, publishImmutableObject, publishImmutableObjectStream,12
} from './store.ts'14
const FILE_ID_PATTERN = /^sha256:([a-f0-9]{64})$/15
const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu17
function 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
}23
function utf8Prefix(value: string, maxBytes: number): string {24
let bytes = 025
let prefix = ''26
for (const character of Buffer.from(value).toString('utf8')) {27
const characterBytes = Buffer.byteLength(character)28
if (bytes + characterBytes > maxBytes) break29
prefix += character30
bytes += characterBytes31
}32
return prefix33
}35
/**36
* Sanitize one caller display name into a safe stored leaf name. Both37
* separator styles are stripped by hand: a POSIX host treats `\` as an38
* ordinary character, so path.basename would keep a Windows client's full39
* local path and leak it into the reference and the session log. Characters40
* Windows refuses in file names become `_` so one reference stays valid on41
* 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
*/45
export 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 = leaf49
.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' : clean56
}58
function 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
}66
/**67
* Derive the absolute immutable-object path for one stored file. The digest68
* 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
*/75
export 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
}80
/** Canonical object path shared by every display name for one digest. */81
function storedFileObjectPath(root: string, sha256: string): string {82
return join(root, 'file-objects', sha256.slice(0, 2), sha256)83
}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
*/91
export 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 ref105
}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
*/113
export 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 ref138
}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
*/147
export 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 = 0160
try {161
for await (const chunk of stream) {162
signal?.throwIfAborted()163
const data = chunk as Buffer164
hash.update(data)165
bytes += data.byteLength166
yield data167
}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
}