redmine-context 1.0.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 (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. package/package.json +93 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Dispatcher de extratores por magic bytes (M3-08, ADR-005).
3
+ *
4
+ * Roteia um arquivo de anexo já baixado para o extrator certo com base no MIME
5
+ * REAL (detectado por magic bytes, ver `./magic.ts`) — NUNCA pela extensão nem
6
+ * pelo `Content-Type` do Redmine. Se a extensão declarada contradiz o magic
7
+ * byte, emite um `logger.warn` (nunca `console.*`) e o MAGIC BYTE VENCE. Tipos
8
+ * sem extrator registrado devolvem `ExtractionResult { status: 'unsupported' }`
9
+ * com metadados — degradação graciosa, sem erro fatal.
10
+ *
11
+ * Este módulo NÃO contém nenhum extrator real (tesseract/OCR é a #51): apenas a
12
+ * interface {@link Extractor}, o {@link ExtractorRegistry} e o roteamento. Um
13
+ * extrator fake nos testes prova o roteamento fim-a-fim.
14
+ */
15
+ import type { ExtractorParams } from '../cache/contract.js';
16
+ import type { Logger } from '../client/index.js';
17
+ import type { ExtractionResult } from '../contract.js';
18
+ /**
19
+ * Opções passadas ao extrator em {@link Extractor.extract}: o MIME real, um logger
20
+ * opcional e o `signal` de CANCELAMENTO (#69/#73).
21
+ */
22
+ export interface ExtractOptions {
23
+ /** MIME REAL do arquivo (detectado por magic bytes) que roteou este extrator. */
24
+ readonly mime: string;
25
+ /** Logger para avisos do extrator; sem default de lib de logging (ADR-003). */
26
+ readonly logger?: Logger;
27
+ /**
28
+ * Sinal de CANCELAMENTO (#69/#73, ADR-005) propagado do topo (fila → dispatch)
29
+ * até o `runWithWatchdog` de cada extrator de mídia, que MATA o subprocesso
30
+ * (`SIGTERM`→`SIGKILL`) ao abortar. Campo OPCIONAL e ADITIVO — extratores que não
31
+ * o repassam mantêm o comportamento anterior. Presente só quando um signal é dado
32
+ * (respeita `exactOptionalPropertyTypes` — nunca injetado como `undefined`).
33
+ */
34
+ readonly signal?: AbortSignal;
35
+ }
36
+ /**
37
+ * Contrato de um extrator plugável (ADR-005). Cada extrator declara os MIMEs que
38
+ * suporta e implementa {@link extract} sobre um arquivo já baixado no cache.
39
+ */
40
+ export interface Extractor {
41
+ /** Identificador estável do extrator (ex.: `tesseract-ocr`). */
42
+ readonly id: string;
43
+ /** Versão do extrator (semântica própria; entra em metadados/cache-key). */
44
+ readonly version: string;
45
+ /** MIMEs REAIS que este extrator aceita (ex.: `['image/png', 'image/jpeg']`). */
46
+ readonly supportedMimes: readonly string[];
47
+ /**
48
+ * Modelo lógico do extrator (ADR-004). Participa da chave attachment-level; se
49
+ * omitido, o pipeline usa {@link id} como fallback estável.
50
+ */
51
+ readonly model?: string;
52
+ /**
53
+ * Parâmetros escalares estáveis do extrator (ADR-004). Participam da chave
54
+ * attachment-level; se omitidos, o pipeline usa `{}`.
55
+ */
56
+ readonly params?: ExtractorParams;
57
+ /**
58
+ * Extrai conteúdo do arquivo.
59
+ *
60
+ * @param filePath - Caminho absoluto do arquivo baixado.
61
+ * @param options - MIME real detectado + logger — ver {@link ExtractOptions}.
62
+ * @returns O {@link ExtractionResult} da extração.
63
+ */
64
+ extract(filePath: string, options: ExtractOptions): Promise<ExtractionResult>;
65
+ }
66
+ /**
67
+ * Registro de extratores indexado por MIME. Mantém a associação MIME → extrator
68
+ * e resolve o roteamento em O(1). Um mesmo extrator pode cobrir vários MIMEs.
69
+ */
70
+ export declare class ExtractorRegistry {
71
+ /** Índice MIME → extrator. O último registrado para um MIME prevalece. */
72
+ private readonly byMime;
73
+ /**
74
+ * Registra um extrator para todos os seus {@link Extractor.supportedMimes}.
75
+ * Registrar de novo o mesmo MIME sobrescreve o extrator anterior.
76
+ *
77
+ * @param extractor - Extrator a registrar.
78
+ * @returns O próprio registry (encadeável).
79
+ */
80
+ register(extractor: Extractor): this;
81
+ /**
82
+ * Encontra o extrator registrado para um MIME.
83
+ *
84
+ * @param mime - MIME REAL detectado por magic bytes.
85
+ * @returns O extrator, ou `undefined` se nenhum cobre esse MIME.
86
+ */
87
+ find(mime: string): Extractor | undefined;
88
+ }
89
+ /** Opções de {@link dispatchExtraction}. */
90
+ export interface DispatchOptions {
91
+ /** Registry consultado para achar o extrator do MIME real. */
92
+ readonly registry: ExtractorRegistry;
93
+ /**
94
+ * Filename declarado pelo Redmine (não confiável) — usado APENAS para detectar
95
+ * e avisar mismatch de extensão vs magic byte. Opcional.
96
+ */
97
+ readonly filename?: string;
98
+ /** Logger para o aviso de mismatch; sem default de lib de logging (ADR-003). */
99
+ readonly logger?: Logger;
100
+ /**
101
+ * Sinal de CANCELAMENTO (#69/#73) repassado ao {@link Extractor.extract} via
102
+ * {@link ExtractOptions.signal} — fecha a fronteira do dispatch para que o abort
103
+ * alcance o subprocesso. Opcional/aditivo (respeita `exactOptionalPropertyTypes`).
104
+ */
105
+ readonly signal?: AbortSignal;
106
+ }
107
+ /**
108
+ * Roteia um arquivo de anexo baixado para o extrator apropriado, decidindo o
109
+ * tipo SEMPRE pelo MIME REAL (magic bytes).
110
+ *
111
+ * Fluxo:
112
+ * 1. Detecta o MIME real do arquivo (magic bytes). Indetectável (vazio/curto/
113
+ * binário desconhecido) → `unsupported` com `reason: 'mime-indetectavel'`.
114
+ * 2. Se `filename` foi dado e sua extensão declara um MIME que CONTRADIZ o real,
115
+ * emite `logger.warn` — e segue com o MIME real (o magic byte VENCE).
116
+ * 3. Busca o extrator no registry. Ausente → `unsupported` com
117
+ * `reason: 'sem-extrator-registrado'` (sem erro fatal).
118
+ * 4. Presente → delega para `extractor.extract(filePath, { mime, logger, signal })`.
119
+ *
120
+ * @param filePath - Caminho absoluto do arquivo baixado.
121
+ * @param options - Registry, filename (opcional) e logger — ver {@link DispatchOptions}.
122
+ * @returns O {@link ExtractionResult} do extrator, ou um `unsupported` gracioso.
123
+ * @throws {Error} Se `filePath` não puder ser lido (propagado de {@link detectMimeFromFile}).
124
+ * @example
125
+ * const result = await dispatchExtraction('/cache/…/original.png', {
126
+ * registry,
127
+ * filename: 'photo.png',
128
+ * logger,
129
+ * });
130
+ * if (result.status === 'unsupported') logger.warn('sem extrator');
131
+ */
132
+ export declare function dispatchExtraction(filePath: string, options: DispatchOptions): Promise<ExtractionResult>;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Dispatcher de extratores por magic bytes (M3-08, ADR-005).
3
+ *
4
+ * Roteia um arquivo de anexo já baixado para o extrator certo com base no MIME
5
+ * REAL (detectado por magic bytes, ver `./magic.ts`) — NUNCA pela extensão nem
6
+ * pelo `Content-Type` do Redmine. Se a extensão declarada contradiz o magic
7
+ * byte, emite um `logger.warn` (nunca `console.*`) e o MAGIC BYTE VENCE. Tipos
8
+ * sem extrator registrado devolvem `ExtractionResult { status: 'unsupported' }`
9
+ * com metadados — degradação graciosa, sem erro fatal.
10
+ *
11
+ * Este módulo NÃO contém nenhum extrator real (tesseract/OCR é a #51): apenas a
12
+ * interface {@link Extractor}, o {@link ExtractorRegistry} e o roteamento. Um
13
+ * extrator fake nos testes prova o roteamento fim-a-fim.
14
+ */
15
+ import { detectMimeFromFile, mimeForExtension } from './magic.js';
16
+ /**
17
+ * Registro de extratores indexado por MIME. Mantém a associação MIME → extrator
18
+ * e resolve o roteamento em O(1). Um mesmo extrator pode cobrir vários MIMEs.
19
+ */
20
+ export class ExtractorRegistry {
21
+ /** Índice MIME → extrator. O último registrado para um MIME prevalece. */
22
+ byMime = new Map();
23
+ /**
24
+ * Registra um extrator para todos os seus {@link Extractor.supportedMimes}.
25
+ * Registrar de novo o mesmo MIME sobrescreve o extrator anterior.
26
+ *
27
+ * @param extractor - Extrator a registrar.
28
+ * @returns O próprio registry (encadeável).
29
+ */
30
+ register(extractor) {
31
+ for (const mime of extractor.supportedMimes) {
32
+ this.byMime.set(mime, extractor);
33
+ }
34
+ return this;
35
+ }
36
+ /**
37
+ * Encontra o extrator registrado para um MIME.
38
+ *
39
+ * @param mime - MIME REAL detectado por magic bytes.
40
+ * @returns O extrator, ou `undefined` se nenhum cobre esse MIME.
41
+ */
42
+ find(mime) {
43
+ return this.byMime.get(mime);
44
+ }
45
+ }
46
+ /**
47
+ * Monta um {@link ExtractionResult} `unsupported` com metadados de diagnóstico,
48
+ * respeitando `exactOptionalPropertyTypes` (não injeta chaves `undefined`).
49
+ *
50
+ * @param mime - MIME real detectado (ou `undefined` se indetectável).
51
+ * @param filename - Filename declarado, se houver.
52
+ * @returns Resultado `unsupported` com `mime`/`metadata` preenchidos quando disponíveis.
53
+ */
54
+ function unsupported(mime, filename) {
55
+ const metadata = {
56
+ reason: mime === undefined ? 'mime-indetectavel' : 'sem-extrator-registrado',
57
+ };
58
+ if (filename !== undefined) {
59
+ metadata.filename = filename;
60
+ }
61
+ return {
62
+ status: 'unsupported',
63
+ ...(mime !== undefined ? { mime } : {}),
64
+ metadata,
65
+ };
66
+ }
67
+ /**
68
+ * Roteia um arquivo de anexo baixado para o extrator apropriado, decidindo o
69
+ * tipo SEMPRE pelo MIME REAL (magic bytes).
70
+ *
71
+ * Fluxo:
72
+ * 1. Detecta o MIME real do arquivo (magic bytes). Indetectável (vazio/curto/
73
+ * binário desconhecido) → `unsupported` com `reason: 'mime-indetectavel'`.
74
+ * 2. Se `filename` foi dado e sua extensão declara um MIME que CONTRADIZ o real,
75
+ * emite `logger.warn` — e segue com o MIME real (o magic byte VENCE).
76
+ * 3. Busca o extrator no registry. Ausente → `unsupported` com
77
+ * `reason: 'sem-extrator-registrado'` (sem erro fatal).
78
+ * 4. Presente → delega para `extractor.extract(filePath, { mime, logger, signal })`.
79
+ *
80
+ * @param filePath - Caminho absoluto do arquivo baixado.
81
+ * @param options - Registry, filename (opcional) e logger — ver {@link DispatchOptions}.
82
+ * @returns O {@link ExtractionResult} do extrator, ou um `unsupported` gracioso.
83
+ * @throws {Error} Se `filePath` não puder ser lido (propagado de {@link detectMimeFromFile}).
84
+ * @example
85
+ * const result = await dispatchExtraction('/cache/…/original.png', {
86
+ * registry,
87
+ * filename: 'photo.png',
88
+ * logger,
89
+ * });
90
+ * if (result.status === 'unsupported') logger.warn('sem extrator');
91
+ */
92
+ export async function dispatchExtraction(filePath, options) {
93
+ const { registry, filename, logger, signal } = options;
94
+ const mime = await detectMimeFromFile(filePath);
95
+ if (mime === undefined) {
96
+ return unsupported(undefined, filename);
97
+ }
98
+ // Mismatch extensão vs magic byte: avisa, mas o magic byte é a fonte de verdade.
99
+ if (filename !== undefined) {
100
+ const declaredMime = mimeForExtension(filename);
101
+ if (declaredMime !== undefined && declaredMime !== mime) {
102
+ logger?.warn(`extract: mismatch de tipo em "${filename}" — extensão sugere ${declaredMime}, ` +
103
+ `mas os magic bytes indicam ${mime}; usando ${mime} (magic byte vence)`);
104
+ }
105
+ }
106
+ const extractor = registry.find(mime);
107
+ if (extractor === undefined) {
108
+ return unsupported(mime, filename);
109
+ }
110
+ return extractor.extract(filePath, {
111
+ mime,
112
+ ...(logger !== undefined ? { logger } : {}),
113
+ ...(signal !== undefined ? { signal } : {}),
114
+ });
115
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Download de anexo com nome derivado de `id + digest` (M3-06, ADR-004).
3
+ *
4
+ * Baixa o binário de um anexo do Redmine de forma AUTENTICADA (reusa o
5
+ * {@link HttpClient} com retry/TLS via `getBinary`) e o grava em disco no layout
6
+ * do ADR-004:
7
+ *
8
+ * `<cache_dir>/<instance_hash>/attachments/<id>-<digest8>/original<ext>`
9
+ *
10
+ * — o MESMO diretório em que as extrações caras do anexo são cacheadas, de modo
11
+ * que o arquivo original e seus derivados coexistam sob a mesma identidade
12
+ * imutável de conteúdo.
13
+ *
14
+ * Segurança (plano): o `filename` que o Redmine reporta NUNCA entra no caminho em
15
+ * disco — apenas sua EXTENSÃO, e ainda assim sanitizada (lowercase, regex
16
+ * `^[a-z0-9]{1,8}$`, senão `.bin`). Um `filename` malicioso como
17
+ * `../../../evil.sh` não consegue escapar do `cache_dir`, pois o nome do arquivo
18
+ * é sempre `original<ext>` e o resto do path deriva só de `id`/`digest` (hex).
19
+ *
20
+ * Atomicidade: o conteúdo é transmitido para um arquivo `.part` único e só então
21
+ * renomeado para o destino (`rename` atômico no mesmo filesystem). Um leitor nunca
22
+ * observa conteúdo parcial com o nome final; se o stream falhar, o `.part` é
23
+ * removido. Idempotência: se o destino (mesmo `id + digest`) já existe, o download
24
+ * é PULADO — anexos são imutáveis, então re-baixar seria desperdício.
25
+ */
26
+ import type { HttpClient } from '../client/index.js';
27
+ import type { Attachment } from '../contract.js';
28
+ /** Opções de {@link downloadAttachment}. */
29
+ export interface DownloadAttachmentOptions {
30
+ /** Raiz do cache em disco (mesma do {@link DiskCacheStore}). */
31
+ cacheDir: string;
32
+ /** URL base da instância Redmine — vira o `instance_hash` do path. */
33
+ instanceUrl: string;
34
+ /**
35
+ * Limite máximo, em bytes, para baixar um anexo. Anexos que declaram (ou
36
+ * transmitem) mais que isso são PULADOS com aviso (ADR-002). Default: 100 MB.
37
+ */
38
+ maxBytes?: number;
39
+ }
40
+ /**
41
+ * Resultado de um download PULADO por exceder o limite de tamanho (ADR-002).
42
+ * Não é uma exceção: é degradação graciosa — o bundle apenas registra o `reason`
43
+ * legível e segue sem o anexo.
44
+ */
45
+ export interface SkippedDownload {
46
+ /** Discriminante do resultado pulado. */
47
+ readonly skipped: true;
48
+ /** Motivo legível do skip, ex.: `anexo pulado: 250 MB excede o limite de 100 MB`. */
49
+ readonly reason: string;
50
+ }
51
+ /**
52
+ * Resultado de {@link downloadAttachment}: o caminho absoluto do arquivo gravado
53
+ * (sucesso) ou um {@link SkippedDownload} quando o anexo excede o limite.
54
+ */
55
+ export type DownloadResult = string | SkippedDownload;
56
+ /**
57
+ * Type guard: indica se o resultado do download foi PULADO por exceder o limite.
58
+ *
59
+ * @param result - Retorno de {@link downloadAttachment}.
60
+ * @returns `true` se for um {@link SkippedDownload}.
61
+ */
62
+ export declare function isSkipped(result: DownloadResult): result is SkippedDownload;
63
+ /**
64
+ * Deriva a extensão SEGURA a partir do `filename` do Redmine. Apenas a extensão é
65
+ * aproveitada (nunca o nome inteiro) e ainda passa por sanitização: minúsculas e
66
+ * regex `^[a-z0-9]{1,8}$`; qualquer coisa fora disso vira `.bin`.
67
+ *
68
+ * @param filename - Nome de arquivo reportado pelo Redmine (não confiável).
69
+ * @returns Extensão com ponto (ex.: `.png`) ou `.bin`.
70
+ * @example
71
+ * safeExtension('photo.PNG'); // '.png'
72
+ * safeExtension('../../../evil.sh'); // '.sh' (mas só a extensão entra no path)
73
+ * safeExtension('noext'); // '.bin'
74
+ */
75
+ export declare function safeExtension(filename: string): string;
76
+ /**
77
+ * Baixa o binário de um anexo do Redmine e o grava no cache local (ADR-004).
78
+ *
79
+ * O download é autenticado via `http.getBinary` (herdando retry/TLS e erros
80
+ * tipados) na rota `/attachments/download/<id>/<filename>`; o `filename` é
81
+ * percent-encoded na URL e NÃO é usado no caminho em disco (só a extensão
82
+ * sanitizada). A escrita é atômica (`.part` + `rename`) e idempotente (não
83
+ * re-baixa se o mesmo `id + digest` já existe).
84
+ *
85
+ * Limite de tamanho (ADR-002, M3-07): há dois portões. O PRÉ-CHECK usa o
86
+ * `attachment.filesize` reportado pelo Redmine (equivalente ao `Content-Length`
87
+ * que o servidor derivaria): se já excede `maxBytes`, o download NEM INICIA e um
88
+ * {@link SkippedDownload} é devolvido — degradação graciosa, sem exceção. O
89
+ * PÓS-CHECK reimpõe o teto sobre o fluxo real byte a byte (ver
90
+ * {@link enforceLimit}): um servidor que mente o tamanho é abortado no meio, o
91
+ * `.part` é limpo e o mesmo resultado skipped é devolvido.
92
+ *
93
+ * Nota: um pré-check pelo header HTTP `Content-Length` exigiria expor os headers
94
+ * de `getBinary`; hoje o `filesize` do contrato cumpre esse papel e o pós-check
95
+ * cobre a divergência. Expor o header é uma melhoria futura registrada.
96
+ *
97
+ * @param http - Client HTTP autenticado da instância — ver {@link HttpClient}.
98
+ * @param attachment - Anexo a baixar — ver {@link Attachment}.
99
+ * @param options - Cache, URL da instância e limite — ver {@link DownloadAttachmentOptions}.
100
+ * @returns Caminho absoluto do `original<ext>` gravado, ou {@link SkippedDownload} se exceder o limite.
101
+ * @throws {RedmineNotFoundError} Se o anexo não existir (404).
102
+ * @throws {RedmineHttpError} Em qualquer outro erro HTTP do download.
103
+ * @throws {Error} Se o stream falhar durante a escrita (o `.part` é removido).
104
+ * @example
105
+ * const result = await downloadAttachment(http, attachment, {
106
+ * cacheDir: '~/.cache/redmine-context',
107
+ * instanceUrl: 'https://redmine.example',
108
+ * });
109
+ * if (isSkipped(result)) logger.warn(result.reason);
110
+ */
111
+ export declare function downloadAttachment(http: HttpClient, attachment: Attachment, options: DownloadAttachmentOptions): Promise<DownloadResult>;
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Download de anexo com nome derivado de `id + digest` (M3-06, ADR-004).
3
+ *
4
+ * Baixa o binário de um anexo do Redmine de forma AUTENTICADA (reusa o
5
+ * {@link HttpClient} com retry/TLS via `getBinary`) e o grava em disco no layout
6
+ * do ADR-004:
7
+ *
8
+ * `<cache_dir>/<instance_hash>/attachments/<id>-<digest8>/original<ext>`
9
+ *
10
+ * — o MESMO diretório em que as extrações caras do anexo são cacheadas, de modo
11
+ * que o arquivo original e seus derivados coexistam sob a mesma identidade
12
+ * imutável de conteúdo.
13
+ *
14
+ * Segurança (plano): o `filename` que o Redmine reporta NUNCA entra no caminho em
15
+ * disco — apenas sua EXTENSÃO, e ainda assim sanitizada (lowercase, regex
16
+ * `^[a-z0-9]{1,8}$`, senão `.bin`). Um `filename` malicioso como
17
+ * `../../../evil.sh` não consegue escapar do `cache_dir`, pois o nome do arquivo
18
+ * é sempre `original<ext>` e o resto do path deriva só de `id`/`digest` (hex).
19
+ *
20
+ * Atomicidade: o conteúdo é transmitido para um arquivo `.part` único e só então
21
+ * renomeado para o destino (`rename` atômico no mesmo filesystem). Um leitor nunca
22
+ * observa conteúdo parcial com o nome final; se o stream falhar, o `.part` é
23
+ * removido. Idempotência: se o destino (mesmo `id + digest`) já existe, o download
24
+ * é PULADO — anexos são imutáveis, então re-baixar seria desperdício.
25
+ */
26
+ import { createHash } from 'node:crypto';
27
+ import { createWriteStream } from 'node:fs';
28
+ import { access, mkdir, rename, rm } from 'node:fs/promises';
29
+ import { extname, join } from 'node:path';
30
+ import { pipeline } from 'node:stream/promises';
31
+ import { instanceHash } from '../cache/index.js';
32
+ import { deriveAttachmentDigest } from '../cache/keys.js';
33
+ /** Subdiretório da camada de attachment sob cada instância (espelha o DiskCacheStore). */
34
+ const ATTACHMENTS_DIR = 'attachments';
35
+ /** Comprimento (chars hex) do digest usado no path do anexo (ADR-004). */
36
+ const DIGEST_PATH_LENGTH = 8;
37
+ /** Nome-base fixo do arquivo original — o filename do Redmine nunca entra aqui. */
38
+ const ORIGINAL_BASENAME = 'original';
39
+ /** Extensão de fallback quando a do anexo é ausente/insegura. */
40
+ const FALLBACK_EXTENSION = '.bin';
41
+ /** Extensão considerada segura: 1–8 caracteres alfanuméricos minúsculos. */
42
+ const SAFE_EXTENSION_PATTERN = /^[a-z0-9]{1,8}$/;
43
+ /** Padrão de um digest já em forma hexadecimal (8–64 chars). */
44
+ const HEX_DIGEST_PATTERN = /^[0-9a-fA-F]{8,64}$/;
45
+ /** Limite default de tamanho de anexo (100 MB), configurável por chamada. */
46
+ const DEFAULT_MAX_BYTES = 100 * 1024 * 1024;
47
+ /** Bytes em 1 MB — base para formatar os motivos de skip de forma legível. */
48
+ const BYTES_PER_MB = 1024 * 1024;
49
+ /**
50
+ * Type guard: indica se o resultado do download foi PULADO por exceder o limite.
51
+ *
52
+ * @param result - Retorno de {@link downloadAttachment}.
53
+ * @returns `true` se for um {@link SkippedDownload}.
54
+ */
55
+ export function isSkipped(result) {
56
+ return typeof result === 'object' && result.skipped === true;
57
+ }
58
+ /**
59
+ * Formata bytes como uma quantidade legível em MB, ex.: `250 MB`, `100 MB`,
60
+ * `1.5 MB`. Usado só para compor os motivos de skip mostrados ao usuário.
61
+ *
62
+ * @param bytes - Quantidade de bytes.
63
+ * @returns Texto em MB com no máximo uma casa decimal.
64
+ */
65
+ function formatMegabytes(bytes) {
66
+ const mb = bytes / BYTES_PER_MB;
67
+ const rounded = Math.round(mb * 10) / 10;
68
+ const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
69
+ return `${text} MB`;
70
+ }
71
+ /**
72
+ * Deriva a extensão SEGURA a partir do `filename` do Redmine. Apenas a extensão é
73
+ * aproveitada (nunca o nome inteiro) e ainda passa por sanitização: minúsculas e
74
+ * regex `^[a-z0-9]{1,8}$`; qualquer coisa fora disso vira `.bin`.
75
+ *
76
+ * @param filename - Nome de arquivo reportado pelo Redmine (não confiável).
77
+ * @returns Extensão com ponto (ex.: `.png`) ou `.bin`.
78
+ * @example
79
+ * safeExtension('photo.PNG'); // '.png'
80
+ * safeExtension('../../../evil.sh'); // '.sh' (mas só a extensão entra no path)
81
+ * safeExtension('noext'); // '.bin'
82
+ */
83
+ export function safeExtension(filename) {
84
+ const ext = extname(filename).replace(/^\./, '').toLowerCase();
85
+ return SAFE_EXTENSION_PATTERN.test(ext) ? `.${ext}` : FALLBACK_EXTENSION;
86
+ }
87
+ /**
88
+ * Calcula o segmento `<digest8>` do path do anexo, espelhando a lógica do
89
+ * {@link DiskCacheStore}: usa `attachment.digest` quando é hex puro; deriva um
90
+ * digest determinístico de `(id, filesize, created_on)` quando ausente (Redmine
91
+ * < 4.x); e re-hasheia por SHA-256 qualquer digest não-hex (defesa contra
92
+ * traversal). O resultado são sempre 8 chars hex — impossível conter `/` ou `..`.
93
+ *
94
+ * @param attachment - Anexo do contrato.
95
+ * @returns Primeiros 8 chars hex do digest normalizado.
96
+ */
97
+ function digest8For(attachment) {
98
+ const raw = attachment.digest !== undefined && attachment.digest !== ''
99
+ ? attachment.digest
100
+ : deriveAttachmentDigest(attachment);
101
+ const hex = HEX_DIGEST_PATTERN.test(raw)
102
+ ? raw.toLowerCase()
103
+ : createHash('sha256').update(raw).digest('hex');
104
+ return hex.slice(0, DIGEST_PATH_LENGTH);
105
+ }
106
+ /**
107
+ * Aguarda o fechamento definitivo de um {@link WriteStream}, destruindo-o se
108
+ * ainda estiver aberto. Necessário antes de remover o `.part`: o `createWriteStream`
109
+ * abre o fd de forma preguiçosa, então um `rm` disparado cedo demais poderia correr
110
+ * com o `open` e deixar o arquivo para trás. Espera o evento `close` (idempotente).
111
+ *
112
+ * @param writable - Stream de escrita a encerrar.
113
+ */
114
+ function settleWritable(writable) {
115
+ return new Promise((resolve) => {
116
+ if (writable.closed) {
117
+ resolve();
118
+ return;
119
+ }
120
+ writable.once('close', () => resolve());
121
+ writable.destroy();
122
+ });
123
+ }
124
+ /** Indica se um caminho existe no filesystem. */
125
+ async function pathExists(target) {
126
+ try {
127
+ await access(target);
128
+ return true;
129
+ }
130
+ catch {
131
+ return false;
132
+ }
133
+ }
134
+ /**
135
+ * Consome um `ReadableStream` web como um `AsyncGenerator` de chunks, para
136
+ * alimentar o `pipeline` do Node sem depender da conversão web→node (evita
137
+ * `Readable.fromWeb` e casts inseguros).
138
+ */
139
+ async function* toChunks(stream) {
140
+ const reader = stream.getReader();
141
+ try {
142
+ for (;;) {
143
+ const { done, value } = await reader.read();
144
+ if (done)
145
+ break;
146
+ if (value !== undefined)
147
+ yield value;
148
+ }
149
+ }
150
+ finally {
151
+ // cancel() de fato ABORTA a leitura da resposta HTTP (corta a banda) —
152
+ // releaseLock() sozinho só liberaria o lock e o corpo continuaria sendo
153
+ // baixado em background (fix review #136). Em conclusão normal (done),
154
+ // cancel é um no-op seguro.
155
+ await reader.cancel().catch(() => reader.releaseLock());
156
+ }
157
+ }
158
+ /**
159
+ * Sentinela interna: sinaliza que o CORPO transmitido ultrapassou `maxBytes`
160
+ * durante a escrita. É capturada em {@link downloadAttachment} e convertida em um
161
+ * {@link SkippedDownload} (não vaza como exceção) — distinta de falhas reais de IO.
162
+ */
163
+ class DownloadLimitExceeded extends Error {
164
+ }
165
+ /**
166
+ * Envolve os chunks contando os bytes acumulados; ao ULTRAPASSAR `maxBytes`,
167
+ * lança {@link DownloadLimitExceeded}, abortando o `pipeline` no meio (o `.part`
168
+ * é então removido). Cobre o servidor que MENTE o tamanho declarado: o limite é
169
+ * imposto de novo em cima do fluxo real, byte a byte, sem carregar tudo em memória.
170
+ *
171
+ * @param chunks - Fonte de chunks (ver {@link toChunks}).
172
+ * @param maxBytes - Teto de bytes aceito para o corpo inteiro.
173
+ */
174
+ async function* enforceLimit(chunks, maxBytes) {
175
+ let total = 0;
176
+ for await (const chunk of chunks) {
177
+ total += chunk.byteLength;
178
+ if (total > maxBytes) {
179
+ throw new DownloadLimitExceeded();
180
+ }
181
+ yield chunk;
182
+ }
183
+ }
184
+ /**
185
+ * Baixa o binário de um anexo do Redmine e o grava no cache local (ADR-004).
186
+ *
187
+ * O download é autenticado via `http.getBinary` (herdando retry/TLS e erros
188
+ * tipados) na rota `/attachments/download/<id>/<filename>`; o `filename` é
189
+ * percent-encoded na URL e NÃO é usado no caminho em disco (só a extensão
190
+ * sanitizada). A escrita é atômica (`.part` + `rename`) e idempotente (não
191
+ * re-baixa se o mesmo `id + digest` já existe).
192
+ *
193
+ * Limite de tamanho (ADR-002, M3-07): há dois portões. O PRÉ-CHECK usa o
194
+ * `attachment.filesize` reportado pelo Redmine (equivalente ao `Content-Length`
195
+ * que o servidor derivaria): se já excede `maxBytes`, o download NEM INICIA e um
196
+ * {@link SkippedDownload} é devolvido — degradação graciosa, sem exceção. O
197
+ * PÓS-CHECK reimpõe o teto sobre o fluxo real byte a byte (ver
198
+ * {@link enforceLimit}): um servidor que mente o tamanho é abortado no meio, o
199
+ * `.part` é limpo e o mesmo resultado skipped é devolvido.
200
+ *
201
+ * Nota: um pré-check pelo header HTTP `Content-Length` exigiria expor os headers
202
+ * de `getBinary`; hoje o `filesize` do contrato cumpre esse papel e o pós-check
203
+ * cobre a divergência. Expor o header é uma melhoria futura registrada.
204
+ *
205
+ * @param http - Client HTTP autenticado da instância — ver {@link HttpClient}.
206
+ * @param attachment - Anexo a baixar — ver {@link Attachment}.
207
+ * @param options - Cache, URL da instância e limite — ver {@link DownloadAttachmentOptions}.
208
+ * @returns Caminho absoluto do `original<ext>` gravado, ou {@link SkippedDownload} se exceder o limite.
209
+ * @throws {RedmineNotFoundError} Se o anexo não existir (404).
210
+ * @throws {RedmineHttpError} Em qualquer outro erro HTTP do download.
211
+ * @throws {Error} Se o stream falhar durante a escrita (o `.part` é removido).
212
+ * @example
213
+ * const result = await downloadAttachment(http, attachment, {
214
+ * cacheDir: '~/.cache/redmine-context',
215
+ * instanceUrl: 'https://redmine.example',
216
+ * });
217
+ * if (isSkipped(result)) logger.warn(result.reason);
218
+ */
219
+ export async function downloadAttachment(http, attachment, options) {
220
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
221
+ // Pré-check: o tamanho reportado já estoura o limite → não inicia o download.
222
+ if (attachment.filesize > maxBytes) {
223
+ return {
224
+ skipped: true,
225
+ reason: `anexo pulado: ${formatMegabytes(attachment.filesize)} excede o limite de ${formatMegabytes(maxBytes)}`,
226
+ };
227
+ }
228
+ const dir = join(options.cacheDir, instanceHash(options.instanceUrl), ATTACHMENTS_DIR, `${attachment.id}-${digest8For(attachment)}`);
229
+ const finalPath = join(dir, `${ORIGINAL_BASENAME}${safeExtension(attachment.filename)}`);
230
+ // Idempotência: anexos são imutáveis; se já baixamos este id+digest, reusa.
231
+ if (await pathExists(finalPath)) {
232
+ return finalPath;
233
+ }
234
+ await mkdir(dir, { recursive: true });
235
+ // O filename entra na URL (percent-encoded) — nunca no path em disco. `%2F`
236
+ // impede que um `/` no filename altere a rota do servidor.
237
+ const downloadPath = `/attachments/download/${attachment.id}/${encodeURIComponent(attachment.filename)}`;
238
+ const stream = await http.getBinary(downloadPath);
239
+ // Escreve-e-renomeia: o destino só surge, completo, após o rename atômico.
240
+ const partPath = `${finalPath}.${createHash('sha256').update(String(Date.now() + Math.random())).digest('hex').slice(0, 12)}.part`;
241
+ const writable = createWriteStream(partPath);
242
+ try {
243
+ await pipeline(enforceLimit(toChunks(stream), maxBytes), writable);
244
+ await rename(partPath, finalPath);
245
+ }
246
+ catch (error) {
247
+ // Fecha o fd (pode ainda estar abrindo) antes de remover, evitando corrida.
248
+ await settleWritable(writable);
249
+ // Nunca deixa conteúdo parcial para trás — seja por limite ou falha de IO.
250
+ await rm(partPath, { force: true });
251
+ // Pós-check: o corpo excedeu o limite → skip gracioso, não exceção.
252
+ if (error instanceof DownloadLimitExceeded) {
253
+ return {
254
+ skipped: true,
255
+ reason: `anexo pulado: conteúdo excede o limite de ${formatMegabytes(maxBytes)}`,
256
+ };
257
+ }
258
+ throw error;
259
+ }
260
+ return finalPath;
261
+ }