shilo-cli 0.5.0 → 0.6.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/bin/shilo.js +21 -2
- package/package.json +1 -1
- package/src/ShiloClient.js +134 -4
- package/src/commands/pull.js +37 -9
- package/src/commands/push.js +178 -50
- package/src/pushindex.js +6 -1
- package/src/textguard.js +70 -19
package/bin/shilo.js
CHANGED
|
@@ -138,9 +138,28 @@ async function main() {
|
|
|
138
138
|
return handler(commandArgs);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Termina con `code` sin llamar a `process.exit()` de inmediato.
|
|
143
|
+
*
|
|
144
|
+
* Llamarlo justo después de un `fetch` aborta el proceso en Windows con
|
|
145
|
+
* `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`: libuv cierra dos
|
|
146
|
+
* veces el handle del pool de conexiones de undici. El comando ya imprimió todo
|
|
147
|
+
* y hasta guardó en disco, pero el shell recibe 127 en vez de 0 — o sea que
|
|
148
|
+
* cualquier script o CI que chequee el resultado de `shilo push` lee basura.
|
|
149
|
+
*
|
|
150
|
+
* Dejar que el loop se vacíe solo evita el crash y no cuesta nada: las conexiones
|
|
151
|
+
* de undici no tienen ref, así que el proceso sale igual de rápido. El timer es
|
|
152
|
+
* el paracaídas por si algo queda colgado; sin ref, no mantiene vivo al proceso
|
|
153
|
+
* por sí mismo, sólo actúa si ya lo estaba.
|
|
154
|
+
*/
|
|
155
|
+
function finish(code) {
|
|
156
|
+
process.exitCode = code;
|
|
157
|
+
setTimeout(() => process.exit(code), 5000).unref();
|
|
158
|
+
}
|
|
159
|
+
|
|
141
160
|
main()
|
|
142
|
-
.then((code) =>
|
|
161
|
+
.then((code) => finish(typeof code === 'number' ? code : 0))
|
|
143
162
|
.catch((err) => {
|
|
144
163
|
ui.error(err && err.message ? err.message : String(err));
|
|
145
|
-
|
|
164
|
+
finish(1);
|
|
146
165
|
});
|
package/package.json
CHANGED
package/src/ShiloClient.js
CHANGED
|
@@ -89,7 +89,7 @@ class ShiloClient {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** Un intento único, sin reintentos. */
|
|
92
|
-
async _fetchOnce(url, { method = 'GET', headers = {}, body = null } = {}) {
|
|
92
|
+
async _fetchOnce(url, { method = 'GET', headers = {}, body = null, raw = false } = {}) {
|
|
93
93
|
const controller = new AbortController();
|
|
94
94
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
95
95
|
let res;
|
|
@@ -97,7 +97,11 @@ class ShiloClient {
|
|
|
97
97
|
res = await fetch(url, {
|
|
98
98
|
method,
|
|
99
99
|
headers,
|
|
100
|
-
|
|
100
|
+
// Un Buffer va tal cual: es el cuerpo de una subida a URL firmada, y
|
|
101
|
+
// pasarlo por JSON.stringify lo convertiría en el texto "{\"type\":
|
|
102
|
+
// \"Buffer\",\"data\":[...]}", que es exactamente la corrupción
|
|
103
|
+
// silenciosa que la subida directa vino a evitar.
|
|
104
|
+
body: body == null ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body),
|
|
101
105
|
signal: controller.signal,
|
|
102
106
|
});
|
|
103
107
|
} catch (err) {
|
|
@@ -111,6 +115,14 @@ class ShiloClient {
|
|
|
111
115
|
}
|
|
112
116
|
clearTimeout(timer);
|
|
113
117
|
|
|
118
|
+
// `raw`: la respuesta son bytes (un binario bajado de una URL firmada) y
|
|
119
|
+
// leerla como texto la decodificaría como UTF-8, que es destruirla. Los
|
|
120
|
+
// errores tampoco se parsean acá: con `raw` el llamador mira `res.status`.
|
|
121
|
+
if (raw) {
|
|
122
|
+
const buffer = res.ok ? Buffer.from(await res.arrayBuffer()) : null;
|
|
123
|
+
return { res, data: buffer };
|
|
124
|
+
}
|
|
125
|
+
|
|
114
126
|
const text = await res.text();
|
|
115
127
|
let data = null;
|
|
116
128
|
if (text) {
|
|
@@ -307,7 +319,7 @@ class ShiloClient {
|
|
|
307
319
|
async _authFetch(url, credentials, { method = 'GET', body = null } = {}) {
|
|
308
320
|
const validCreds = await this.ensureValidToken(credentials);
|
|
309
321
|
const headers = { Authorization: `Bearer ${validCreds.jwt}` };
|
|
310
|
-
if (body != null) headers['Content-Type'] = 'application/json';
|
|
322
|
+
if (body != null && !Buffer.isBuffer(body)) headers['Content-Type'] = 'application/json';
|
|
311
323
|
const { res, data } = await this._fetch(url, { method, headers, body });
|
|
312
324
|
return { res, data, credentials: validCreds };
|
|
313
325
|
}
|
|
@@ -366,7 +378,18 @@ class ShiloClient {
|
|
|
366
378
|
}
|
|
367
379
|
const file = data && data.file ? data.file : {};
|
|
368
380
|
const content = data && data.content !== undefined ? data.content : null;
|
|
369
|
-
|
|
381
|
+
// Un binario no viene con contenido: viene con una URL firmada para bajarlo
|
|
382
|
+
// directo de Storage (ver `downloadBinary`). Los dos campos se pegan al
|
|
383
|
+
// `file` para que el llamador decida con un solo objeto en la mano.
|
|
384
|
+
return {
|
|
385
|
+
file: {
|
|
386
|
+
...file,
|
|
387
|
+
content,
|
|
388
|
+
is_binary: Boolean(data && data.is_binary),
|
|
389
|
+
download_url: (data && data.download_url) || null,
|
|
390
|
+
},
|
|
391
|
+
credentials: creds,
|
|
392
|
+
};
|
|
370
393
|
}
|
|
371
394
|
|
|
372
395
|
/**
|
|
@@ -459,6 +482,113 @@ class ShiloClient {
|
|
|
459
482
|
return { results, summary, credentials: creds };
|
|
460
483
|
}
|
|
461
484
|
|
|
485
|
+
/**
|
|
486
|
+
* Baja los bytes de una URL firmada. Devuelve un Buffer.
|
|
487
|
+
*
|
|
488
|
+
* No lleva `Authorization`: la firma ES la autorización, y mandar el JWT a una
|
|
489
|
+
* URL de Storage sería filtrarlo a un origen que no lo necesita.
|
|
490
|
+
*/
|
|
491
|
+
async downloadBinary(signedUrl) {
|
|
492
|
+
const { res, data } = await this._fetch(signedUrl, { method: 'GET', raw: true });
|
|
493
|
+
if (!res.ok || !data) {
|
|
494
|
+
throw new ShiloError(`No se pudo descargar el contenido: HTTP ${res.status}`, {
|
|
495
|
+
status: res.status,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
return data;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Sube un archivo binario: URL firmada, tres pasos.
|
|
503
|
+
*
|
|
504
|
+
* El contenido NO puede viajar por `writeBatch` ni por `writeFile`: esos
|
|
505
|
+
* mandan el archivo como string dentro de un JSON, y el backend lo hashea con
|
|
506
|
+
* `Buffer.from(content, 'utf8')`. Un binario se corrompe ahí, ANTES de
|
|
507
|
+
* hashearse, y el grafo registra el resultado corrupto como válido — no hay
|
|
508
|
+
* verificador que lo note, porque para el motor no pasó nada raro. Por eso el
|
|
509
|
+
* byte va directo a Storage y nunca entra en un payload JSON.
|
|
510
|
+
*
|
|
511
|
+
* Los tres pasos:
|
|
512
|
+
* 1. `begin` — el servidor firma una URL contra la ruta por contenido del
|
|
513
|
+
* blob. Si ese contenido ya está en el proyecto responde
|
|
514
|
+
* `exists: true` y no hay nada que transferir: el archivo ya
|
|
515
|
+
* está ahí, aunque sea con otro nombre.
|
|
516
|
+
* 2. PUT — los bytes, directo a Storage. No pasa por la función
|
|
517
|
+
* serverless, así que el límite de payload de Vercel deja de
|
|
518
|
+
* aplicar.
|
|
519
|
+
* 3. `commit` — el servidor verifica y apunta la fila de `files` al blob.
|
|
520
|
+
*
|
|
521
|
+
* `entry`: { path, buffer, mimeType }.
|
|
522
|
+
* Devuelve `{ result, uploaded, credentials }`, donde `result` tiene la misma
|
|
523
|
+
* forma que un elemento de `writeBatch` (`ok`, `action`, `file`) y `uploaded`
|
|
524
|
+
* dice si hubo bytes en el aire.
|
|
525
|
+
*/
|
|
526
|
+
async uploadBinary(projectId, entry, credentials) {
|
|
527
|
+
const url = `${this.baseURL}/api/files/${projectId}/direct`;
|
|
528
|
+
const payload = {
|
|
529
|
+
path: entry.path,
|
|
530
|
+
size: entry.buffer.length,
|
|
531
|
+
content_hash: entry.contentHash,
|
|
532
|
+
mime_type: entry.mimeType || 'application/octet-stream',
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const begun = await this._authFetch(url, credentials, {
|
|
536
|
+
method: 'POST',
|
|
537
|
+
body: { action: 'begin', ...payload },
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
if (!begun.res.ok) {
|
|
541
|
+
throw new ShiloError(
|
|
542
|
+
`No se pudo preparar la subida de "${entry.path}": ${this._errMessage(begun.data, `HTTP ${begun.res.status}`)}`,
|
|
543
|
+
{
|
|
544
|
+
status: begun.res.status,
|
|
545
|
+
body: begun.data,
|
|
546
|
+
notSupported: begun.res.status === 404 || begun.res.status === 405,
|
|
547
|
+
}
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let uploaded = false;
|
|
552
|
+
const upload = begun.data && begun.data.upload;
|
|
553
|
+
|
|
554
|
+
if (upload && upload.url) {
|
|
555
|
+
const { res } = await this._fetch(upload.url, {
|
|
556
|
+
method: 'PUT',
|
|
557
|
+
headers: {
|
|
558
|
+
'Content-Type': entry.mimeType || 'application/octet-stream',
|
|
559
|
+
// `x-upsert: false` es el default de una URL firmada de subida y se
|
|
560
|
+
// deja explícito: si el objeto ya existiera, pisarlo reemplazaría el
|
|
561
|
+
// contenido de un blob conservando su identidad, que es justo el
|
|
562
|
+
// ataque que el direccionamiento por contenido tiene que impedir.
|
|
563
|
+
'x-upsert': 'false',
|
|
564
|
+
},
|
|
565
|
+
body: entry.buffer,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
if (!res.ok) {
|
|
569
|
+
throw new ShiloError(
|
|
570
|
+
`No se pudo subir el contenido de "${entry.path}": HTTP ${res.status}`,
|
|
571
|
+
{ status: res.status }
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
uploaded = true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const done = await this._authFetch(url, begun.credentials, {
|
|
578
|
+
method: 'POST',
|
|
579
|
+
body: { action: 'commit', ...payload },
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
if (!done.res.ok) {
|
|
583
|
+
throw new ShiloError(
|
|
584
|
+
`No se pudo registrar "${entry.path}": ${this._errMessage(done.data, `HTTP ${done.res.status}`)}`,
|
|
585
|
+
{ status: done.res.status, body: done.data }
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return { result: done.data, uploaded, credentials: done.credentials };
|
|
590
|
+
}
|
|
591
|
+
|
|
462
592
|
/**
|
|
463
593
|
* Crea una carpeta bajo `parentPath` (vacío = raíz). Un 409 significa que ya
|
|
464
594
|
* existe: no es fatal, devolvemos `{ folder, existed: true }`.
|
package/src/commands/pull.js
CHANGED
|
@@ -30,6 +30,16 @@ function safeTarget(destDir, repoPath) {
|
|
|
30
30
|
return { ok: true, target };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Los bytes de un archivo local, o null si no existe o no se puede leer. */
|
|
34
|
+
function readLocalBytes(absPath) {
|
|
35
|
+
try {
|
|
36
|
+
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) return null;
|
|
37
|
+
return fs.readFileSync(absPath);
|
|
38
|
+
} catch (_) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
/**
|
|
34
44
|
* shilo pull [carpeta] [--project <uuid>] [--base-url <url>] [--dry-run] [--yes] [--force]
|
|
35
45
|
*
|
|
@@ -72,9 +82,13 @@ async function pull(args) {
|
|
|
72
82
|
ui.warn(`no se pudo listar ${ui.c.dim(err.folderPath || '(raíz)')} — ${err.reason}`);
|
|
73
83
|
}
|
|
74
84
|
|
|
75
|
-
// 1)
|
|
85
|
+
// 1) Descartar sólo lo que no se puede escribir con seguridad.
|
|
86
|
+
//
|
|
87
|
+
// Los binarios ya NO se saltean (Etapa 2 del PLAN_MOTOR_SHILO): el GET
|
|
88
|
+
// devuelve una URL firmada en vez de contenido, y se bajan como bytes. Lo
|
|
89
|
+
// que se saltaba antes no era una limitación del formato sino del camino:
|
|
90
|
+
// el contenido viajaba como texto y un PNG volvía mutilado.
|
|
76
91
|
const candidates = [];
|
|
77
|
-
const skippedBinary = [];
|
|
78
92
|
const unsafe = [];
|
|
79
93
|
for (const f of tree.files) {
|
|
80
94
|
const safe = safeTarget(dir, f.repoPath);
|
|
@@ -82,15 +96,12 @@ async function pull(args) {
|
|
|
82
96
|
unsafe.push({ repoPath: f.repoPath, reason: safe.reason });
|
|
83
97
|
continue;
|
|
84
98
|
}
|
|
85
|
-
|
|
86
|
-
skippedBinary.push({ repoPath: f.repoPath, reason: `mime no textual (${f.mime_type})` });
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
candidates.push({ ...f, target: safe.target });
|
|
99
|
+
candidates.push({ ...f, target: safe.target, binary: !sync.isTextMime(f.mime_type) });
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
for (const u of unsafe) ui.warn(`saltado ${ui.c.dim(u.repoPath)} — ${u.reason}`);
|
|
93
|
-
|
|
103
|
+
|
|
104
|
+
const binaryCount = candidates.filter((f) => f.binary).length;
|
|
94
105
|
|
|
95
106
|
// 2) Clasificar contra lo que hay en disco, sin bajar nada todavía.
|
|
96
107
|
// `maybeSame` (mismo tamaño) se confirma recién al descargar el contenido.
|
|
@@ -116,7 +127,7 @@ async function pull(args) {
|
|
|
116
127
|
` ${ui.c.yellow('en conflicto:')} ${conflicting.length} ${ui.c.dim('(difieren en tamaño del local)')}\n` +
|
|
117
128
|
` ${ui.c.dim('posiblemente iguales:')} ${maybeSame.length}\n` +
|
|
118
129
|
` ${ui.c.dim('sólo locales:')} ${onlyLocal.length} ${ui.c.dim('(el pull no los toca)')}` +
|
|
119
|
-
(
|
|
130
|
+
(binaryCount ? `\n ${ui.c.dim('binarios:')} ${binaryCount}` : '') +
|
|
120
131
|
(unsafe.length ? `\n ${ui.c.red('inseguros:')} ${unsafe.length}` : '')
|
|
121
132
|
);
|
|
122
133
|
ui.info('');
|
|
@@ -186,6 +197,23 @@ async function pull(args) {
|
|
|
186
197
|
const outcomes = await sync.runPool(toDownload, CONCURRENCY, async (f) => {
|
|
187
198
|
const creds = await mgr.valid();
|
|
188
199
|
const read = await client.readFile(projectId, f.repoPath, creds);
|
|
200
|
+
|
|
201
|
+
// Binario: el servidor no manda contenido, manda una URL firmada. Los bytes
|
|
202
|
+
// se escriben tal cual, sin encoding: pasarlos por 'utf8' es exactamente lo
|
|
203
|
+
// que corrompía los binarios antes de esta etapa.
|
|
204
|
+
if (read.file && read.file.is_binary && read.file.download_url) {
|
|
205
|
+
const bytes = await client.downloadBinary(read.file.download_url);
|
|
206
|
+
|
|
207
|
+
const currentBytes = readLocalBytes(f.target);
|
|
208
|
+
if (currentBytes !== null && currentBytes.equals(bytes)) {
|
|
209
|
+
return { repoPath: f.repoPath, skipped: true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fs.mkdirSync(path.dirname(f.target), { recursive: true });
|
|
213
|
+
fs.writeFileSync(f.target, bytes);
|
|
214
|
+
return { repoPath: f.repoPath, skipped: false };
|
|
215
|
+
}
|
|
216
|
+
|
|
189
217
|
const content = read.file && read.file.content != null ? read.file.content : '';
|
|
190
218
|
|
|
191
219
|
const current = sync.readLocalText(f.target);
|
package/src/commands/push.js
CHANGED
|
@@ -13,6 +13,7 @@ const { walk } = require('../walk');
|
|
|
13
13
|
const {
|
|
14
14
|
classifyContent,
|
|
15
15
|
looksBinary,
|
|
16
|
+
binaryMimeType,
|
|
16
17
|
extensionOf,
|
|
17
18
|
BINARY_EXTENSIONS,
|
|
18
19
|
MAX_FILE_BYTES,
|
|
@@ -54,7 +55,9 @@ const SKIP_LABELS = {
|
|
|
54
55
|
symlink: 'symlinks (no soportados)',
|
|
55
56
|
name: 'nombre no permitido por Shilo',
|
|
56
57
|
depth: 'demasiado anidados',
|
|
57
|
-
|
|
58
|
+
// Los binarios ya NO se saltean: desde la Etapa 2 suben por URL firmada. Este
|
|
59
|
+
// motivo quedó sólo para lo que supera el techo de tamaño.
|
|
60
|
+
size: `mayores a ${MAX_FILE_BYTES / (1024 * 1024)} MB`,
|
|
58
61
|
};
|
|
59
62
|
|
|
60
63
|
/** Lee como mucho `n` bytes del principio del archivo. */
|
|
@@ -78,6 +81,12 @@ function readHead(absPath, n) {
|
|
|
78
81
|
*
|
|
79
82
|
* Sólo se leen los primeros KB de cada archivo; el contenido completo se lee
|
|
80
83
|
* recién cuando se sabe que ese archivo se sube.
|
|
84
|
+
*
|
|
85
|
+
* Desde la Etapa 2 esta fase ya no descarta binarios: los MARCA. Un archivo con
|
|
86
|
+
* `binary: true` sube por URL firmada (`client.uploadBinary`) en vez de por el
|
|
87
|
+
* lote JSON, porque el lote hashea el contenido como utf8 y ahí un binario se
|
|
88
|
+
* corrompe antes de que nadie pueda notarlo. Lo único que sigue siendo motivo de
|
|
89
|
+
* salteo es superar el techo de tamaño.
|
|
81
90
|
*/
|
|
82
91
|
function preflight(dir) {
|
|
83
92
|
const { files, skipped, ignored, gitDirs } = walk(dir);
|
|
@@ -89,41 +98,43 @@ function preflight(dir) {
|
|
|
89
98
|
for (const f of files) {
|
|
90
99
|
const name = path.basename(f.relPath);
|
|
91
100
|
|
|
92
|
-
const ext = extensionOf(name);
|
|
93
|
-
if (ext && BINARY_EXTENSIONS.has(ext)) {
|
|
94
|
-
skips.push({ relPath: f.relPath, reason: `extensión binaria .${ext}`, code: 'binary' });
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
101
|
if (f.size > MAX_FILE_BYTES) {
|
|
98
|
-
skips.push({ relPath: f.relPath, reason: `supera 25 MB (${f.size} bytes)`, code: 'binary' });
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
let head;
|
|
103
|
-
try {
|
|
104
|
-
head = readHead(f.absPath, HEAD_BYTES);
|
|
105
|
-
} catch (e) {
|
|
106
|
-
skips.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}`, code: 'unreadable' });
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (looksBinary(head)) {
|
|
111
102
|
skips.push({
|
|
112
103
|
relPath: f.relPath,
|
|
113
|
-
reason:
|
|
114
|
-
code: '
|
|
104
|
+
reason: `supera ${MAX_FILE_BYTES / (1024 * 1024)} MB (${f.size} bytes)`,
|
|
105
|
+
code: 'size',
|
|
115
106
|
});
|
|
116
107
|
continue;
|
|
117
108
|
}
|
|
118
109
|
|
|
119
|
-
|
|
110
|
+
const ext = extensionOf(name);
|
|
111
|
+
let binary = Boolean(ext && BINARY_EXTENSIONS.has(ext));
|
|
112
|
+
let head = null;
|
|
113
|
+
|
|
114
|
+
// Un archivo cuya extensión ya dice que es binario no hace falta leerlo para
|
|
115
|
+
// saberlo, y leerlo sólo serviría para escanearle secretos — que en un JPEG
|
|
116
|
+
// son ruido.
|
|
117
|
+
if (!binary) {
|
|
118
|
+
try {
|
|
119
|
+
head = readHead(f.absPath, HEAD_BYTES);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
skips.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}`, code: 'unreadable' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
binary = looksBinary(head);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!binary) {
|
|
128
|
+
findings.push(...secrets.inspect(f.relPath, head.toString('utf8')));
|
|
129
|
+
}
|
|
120
130
|
|
|
121
131
|
uploads.push({
|
|
122
132
|
relPath: f.relPath,
|
|
123
133
|
absPath: f.absPath,
|
|
124
134
|
size: f.size,
|
|
125
135
|
mtimeMs: f.mtimeMs,
|
|
126
|
-
|
|
136
|
+
binary,
|
|
137
|
+
mimeType: binary ? binaryMimeType(name) : guessMime(f.relPath),
|
|
127
138
|
content: null,
|
|
128
139
|
});
|
|
129
140
|
}
|
|
@@ -301,7 +312,7 @@ async function push(args) {
|
|
|
301
312
|
}
|
|
302
313
|
|
|
303
314
|
if (uploads.length === 0) {
|
|
304
|
-
ui.info('No hay archivos
|
|
315
|
+
ui.info('No hay archivos para subir.');
|
|
305
316
|
ui.info(ui.c.dim(`(${ignored} ignorados, ${skipped.length} salteados)`));
|
|
306
317
|
return 0;
|
|
307
318
|
}
|
|
@@ -323,7 +334,8 @@ async function push(args) {
|
|
|
323
334
|
|
|
324
335
|
ui.info(
|
|
325
336
|
`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — ` +
|
|
326
|
-
`${uploads.length} archivo(s)
|
|
337
|
+
`${uploads.length} archivo(s) (${uploads.filter((u) => u.binary).length} binario(s)), ` +
|
|
338
|
+
`${ignored} ignorado(s), ` +
|
|
327
339
|
`${skipped.length} salteado(s).`
|
|
328
340
|
);
|
|
329
341
|
|
|
@@ -457,6 +469,19 @@ async function push(args) {
|
|
|
457
469
|
}
|
|
458
470
|
|
|
459
471
|
const content = await loadContent(u);
|
|
472
|
+
|
|
473
|
+
// Un binario no se compara bajando el contenido: el GET devuelve una URL
|
|
474
|
+
// firmada, no bytes, justamente para no arrastrar el archivo por el
|
|
475
|
+
// payload de la función. Sin `content_hash` remoto no queda con qué
|
|
476
|
+
// comparar, así que se asume modificado. El servidor deduplica: si el
|
|
477
|
+
// contenido es el mismo, el blob ya existe y no se transfiere un byte.
|
|
478
|
+
if (u.binary) {
|
|
479
|
+
const creds = await mgr.valid();
|
|
480
|
+
const meta = await client.getFileMeta(projectId, u.relPath, creds);
|
|
481
|
+
if (!meta.exists) return { kind: 'added', u: { ...u, content } };
|
|
482
|
+
return { kind: 'modified', u: { ...u, content, remote: meta.file } };
|
|
483
|
+
}
|
|
484
|
+
|
|
460
485
|
const creds = await mgr.valid();
|
|
461
486
|
const read = await client.readFile(projectId, u.relPath, creds);
|
|
462
487
|
if (read.file && read.file.content === content) {
|
|
@@ -666,13 +691,23 @@ async function push(args) {
|
|
|
666
691
|
|
|
667
692
|
// Empaquetado por cantidad Y por tamaño. Un archivo más grande que el
|
|
668
693
|
// presupuesto termina solo en su lote, y ahí se sube por el endpoint
|
|
669
|
-
// individual
|
|
694
|
+
// individual.
|
|
695
|
+
//
|
|
696
|
+
// Los lotes son HOMOGÉNEOS: o todo texto, o todo binario. Los dos grupos se
|
|
697
|
+
// escriben por caminos distintos —lote JSON contra URL firmada— y un lote
|
|
698
|
+
// mezclado obligaría a partirlo en el momento de escribir, justo donde después
|
|
699
|
+
// hay que volver a juntar los cambios para un solo commit. Separarlos acá
|
|
700
|
+
// deja esa parte sin ramas.
|
|
701
|
+
//
|
|
702
|
+
// El presupuesto de bytes no se le aplica a los binarios: su contenido nunca
|
|
703
|
+
// entra en el payload de un request, así que un lote de diez PDF de 30 MB son
|
|
704
|
+
// diez subidas directas y ningún JSON grande.
|
|
670
705
|
const batches = [];
|
|
671
|
-
{
|
|
706
|
+
for (const group of [toUpload.filter((e) => !e.u.binary), toUpload.filter((e) => e.u.binary)]) {
|
|
672
707
|
let current = [];
|
|
673
708
|
let currentBytes = 0;
|
|
674
|
-
for (const e of
|
|
675
|
-
const bytes = typeof e.u.size === 'number' ? e.u.size : 0;
|
|
709
|
+
for (const e of group) {
|
|
710
|
+
const bytes = e.u.binary ? 0 : typeof e.u.size === 'number' ? e.u.size : 0;
|
|
676
711
|
const full =
|
|
677
712
|
current.length >= BATCH_MAX_FILES || currentBytes + bytes > BATCH_MAX_BYTES;
|
|
678
713
|
if (current.length > 0 && full) {
|
|
@@ -719,11 +754,63 @@ async function push(args) {
|
|
|
719
754
|
// faltara caemos a lo que sabíamos del árbol remoto.
|
|
720
755
|
change.previous_storage_path =
|
|
721
756
|
f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
|
|
757
|
+
// El hash del contenido que estamos reemplazando lo sabe únicamente el
|
|
758
|
+
// cliente: cuando el commit llega al servidor, `files.content_hash` ya
|
|
759
|
+
// fue pisado con el contenido nuevo. Puede ser null en archivos
|
|
760
|
+
// anteriores a la migración 010.
|
|
761
|
+
if (e.u.remote && e.u.remote.content_hash) {
|
|
762
|
+
change.previous_content_hash = e.u.remote.content_hash;
|
|
763
|
+
}
|
|
722
764
|
}
|
|
723
765
|
return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
|
|
724
766
|
});
|
|
725
767
|
}
|
|
726
768
|
|
|
769
|
+
/**
|
|
770
|
+
* Camino de los binarios: URL firmada, un archivo a la vez y en paralelo.
|
|
771
|
+
*
|
|
772
|
+
* No se agrupan en un request como el texto porque no hay nada que agrupar:
|
|
773
|
+
* cada byte va directo a Storage. Lo que sí se comparte es el control de
|
|
774
|
+
* ritmo, que es el que evita que veinte subidas de 30 MB salgan juntas.
|
|
775
|
+
*
|
|
776
|
+
* El endpoint crea las carpetas intermedias por su cuenta, igual que el lote,
|
|
777
|
+
* así que acá no hace falta `ensureFolders()`.
|
|
778
|
+
*/
|
|
779
|
+
async function writeViaDirect(entries) {
|
|
780
|
+
const results = await sync.runPool(entries, throttle, async (e) => {
|
|
781
|
+
const creds = await mgr.valid();
|
|
782
|
+
const { result } = await client.uploadBinary(
|
|
783
|
+
projectId,
|
|
784
|
+
{
|
|
785
|
+
path: e.u.relPath,
|
|
786
|
+
buffer: e.content,
|
|
787
|
+
contentHash: pushindex.hashContent(e.content),
|
|
788
|
+
mimeType: e.u.mimeType,
|
|
789
|
+
},
|
|
790
|
+
creds
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
const f = (result && result.file) || {};
|
|
794
|
+
const action = result && result.action === 'created' ? 'added' : 'modified';
|
|
795
|
+
const change = { file_id: f.id, action, new_storage_path: f.storage_path };
|
|
796
|
+
|
|
797
|
+
if (action === 'modified') {
|
|
798
|
+
change.previous_storage_path =
|
|
799
|
+
f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
|
|
800
|
+
// El hash de lo que estamos reemplazando: el servidor lo lee antes de
|
|
801
|
+
// pisar la fila, que es el único momento en que existe. Si no vino, lo
|
|
802
|
+
// sabemos por el árbol remoto.
|
|
803
|
+
const previous =
|
|
804
|
+
f.previous_content_hash || (e.u.remote && e.u.remote.content_hash) || null;
|
|
805
|
+
if (previous) change.previous_content_hash = previous;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
return results.map((r) => (r && r.__error ? { ok: false, reason: r.__error } : r));
|
|
812
|
+
}
|
|
813
|
+
|
|
727
814
|
/** Camino archivo-por-archivo: server sin /batch, o archivo demasiado grande. */
|
|
728
815
|
async function writeViaSingle(entries) {
|
|
729
816
|
await ensureFolders();
|
|
@@ -780,6 +867,10 @@ async function push(args) {
|
|
|
780
867
|
action: 'modified',
|
|
781
868
|
previous_storage_path: u.remote ? u.remote.storage_path : upd.file.storage_path,
|
|
782
869
|
new_storage_path: upd.file.storage_path,
|
|
870
|
+
// Ver la nota en writeViaBatch: el hash previo sólo existe acá.
|
|
871
|
+
...(u.remote && u.remote.content_hash
|
|
872
|
+
? { previous_content_hash: u.remote.content_hash }
|
|
873
|
+
: {}),
|
|
783
874
|
},
|
|
784
875
|
};
|
|
785
876
|
});
|
|
@@ -811,32 +902,50 @@ async function push(args) {
|
|
|
811
902
|
}
|
|
812
903
|
if (loaded.length === 0) continue;
|
|
813
904
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
905
|
+
// El lote se armó con lo que dijo el preflight, que sólo miró los primeros
|
|
906
|
+
// 8 KB. `loadContent` acaba de leer el archivo entero y pudo reclasificarlo,
|
|
907
|
+
// así que la partición se rehace ACÁ, sobre el contenido de verdad. Mandar
|
|
908
|
+
// un Buffer por el lote JSON lo convertiría en `{"type":"Buffer",...}`, que
|
|
909
|
+
// es la corrupción silenciosa que toda esta etapa existe para evitar.
|
|
910
|
+
const binaries = loaded.filter((e) => e.u.binary);
|
|
911
|
+
const texts = loaded.filter((e) => !e.u.binary);
|
|
912
|
+
|
|
913
|
+
const outcomeByEntry = new Map();
|
|
914
|
+
|
|
915
|
+
if (binaries.length > 0) {
|
|
916
|
+
// Un fallo acá es por archivo, no del lote: `writeViaDirect` devuelve un
|
|
917
|
+
// resultado por entrada y los errores ya vienen adentro.
|
|
918
|
+
const res = await writeViaDirect(binaries);
|
|
919
|
+
binaries.forEach((e, i) => outcomeByEntry.set(e, res[i]));
|
|
920
|
+
}
|
|
817
921
|
|
|
818
|
-
if (
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
922
|
+
if (texts.length > 0) {
|
|
923
|
+
const oversize =
|
|
924
|
+
texts.length === 1 && Buffer.byteLength(texts[0].content, 'utf8') > BATCH_MAX_BYTES;
|
|
925
|
+
let res = null;
|
|
926
|
+
|
|
927
|
+
if (batchSupported && !oversize) {
|
|
928
|
+
try {
|
|
929
|
+
res = await writeViaBatch(texts);
|
|
930
|
+
} catch (err) {
|
|
931
|
+
if (err.notSupported) {
|
|
932
|
+
batchSupported = false;
|
|
933
|
+
upProg.clear();
|
|
934
|
+
ui.warn('El servidor no acepta escritura por lotes; se sube archivo por archivo.');
|
|
935
|
+
} else {
|
|
936
|
+
// Falla del request entero (red, 5xx, permisos): ningún archivo de
|
|
937
|
+
// texto del lote se escribió. Los binarios ya se resolvieron aparte
|
|
938
|
+
// y no los arrastra.
|
|
939
|
+
res = texts.map(() => ({ ok: false, reason: err.message }));
|
|
832
940
|
}
|
|
833
|
-
tickUp(loaded.length);
|
|
834
|
-
continue;
|
|
835
941
|
}
|
|
836
942
|
}
|
|
943
|
+
|
|
944
|
+
if (!res) res = await writeViaSingle(texts);
|
|
945
|
+
texts.forEach((e, i) => outcomeByEntry.set(e, res[i]));
|
|
837
946
|
}
|
|
838
947
|
|
|
839
|
-
|
|
948
|
+
const outcomes = loaded.map((e) => outcomeByEntry.get(e));
|
|
840
949
|
config.persistCredsIfChanged(cfg, mgr.current);
|
|
841
950
|
|
|
842
951
|
const batchChanges = [];
|
|
@@ -923,13 +1032,32 @@ async function push(args) {
|
|
|
923
1032
|
|
|
924
1033
|
/**
|
|
925
1034
|
* Lee el contenido completo de un archivo, cacheado en la propia entrada.
|
|
926
|
-
*
|
|
1035
|
+
*
|
|
1036
|
+
* Devuelve un `string` para texto y un `Buffer` para binarios. Esa diferencia
|
|
1037
|
+
* es la que decide por qué camino sube el archivo, así que se re-clasifica acá y
|
|
1038
|
+
* no se confía en lo que dijo el preflight: entre una cosa y la otra el archivo
|
|
1039
|
+
* pudo cambiar, y el preflight sólo miró los primeros 8 KB.
|
|
1040
|
+
*
|
|
1041
|
+
* `u.binary` y `u.mimeType` se corrigen con lo que digan los bytes completos.
|
|
1042
|
+
* Un archivo que el preflight leyó como texto y resultó binario tiene que
|
|
1043
|
+
* cambiar de camino, no subirse mal por el que ya tenía asignado.
|
|
927
1044
|
*/
|
|
928
1045
|
async function loadContent(u) {
|
|
929
1046
|
if (u.content != null) return u.content;
|
|
1047
|
+
|
|
930
1048
|
const buf = await fs.promises.readFile(u.absPath);
|
|
931
1049
|
const cls = classifyContent(path.basename(u.relPath), buf);
|
|
1050
|
+
|
|
1051
|
+
if (cls.isBinary) {
|
|
1052
|
+
u.binary = true;
|
|
1053
|
+
u.mimeType = cls.mimeType;
|
|
1054
|
+
u.content = cls.buffer;
|
|
1055
|
+
return u.content;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
932
1058
|
if (!cls.isText) throw new Error(cls.reason);
|
|
1059
|
+
|
|
1060
|
+
u.binary = false;
|
|
933
1061
|
u.content = cls.content;
|
|
934
1062
|
return u.content;
|
|
935
1063
|
}
|
package/src/pushindex.js
CHANGED
|
@@ -40,7 +40,12 @@ function indexPath(dir) {
|
|
|
40
40
|
|
|
41
41
|
/** SHA-256 hex de un contenido utf8. */
|
|
42
42
|
function hashContent(content) {
|
|
43
|
-
|
|
43
|
+
// Un binario llega como Buffer y un archivo de texto como string. Se hashean
|
|
44
|
+
// los BYTES en los dos casos: el hash tiene que ser el mismo que calcula el
|
|
45
|
+
// backend sobre el contenido crudo (`files.content_hash`, migración 010), o
|
|
46
|
+
// el push reportaría "modificado" en cada corrida.
|
|
47
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8');
|
|
48
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
/**
|
package/src/textguard.js
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Validación de nombres y
|
|
4
|
+
* Validación de nombres y clasificación de contenido para el push.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* como utf8, así que un binario se
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Hasta la Etapa 2 del PLAN_MOTOR_SHILO, Shilo sólo aceptaba texto: el endpoint
|
|
7
|
+
* por ruta sube el body como utf8, así que un binario se corrompía. Ahora los
|
|
8
|
+
* binarios SÍ se suben, pero por otro camino —`POST /api/files/:id/direct`, con
|
|
9
|
+
* URL firmada, sin pasar por JSON— y por eso este módulo ya no los rechaza:
|
|
10
|
+
* los CLASIFICA, para que el push sepa por dónde mandar cada archivo.
|
|
11
|
+
*
|
|
12
|
+
* Lo que sigue rechazando, y no cambió: los nombres que rompen una ruta o no son
|
|
13
|
+
* portables, y los archivos que superan el techo de tamaño. El escaneo de
|
|
14
|
+
* secretos también se queda; vive en `secrets.js` y sólo corre sobre texto,
|
|
15
|
+
* porque buscar patrones de credenciales en un JPEG es ruido.
|
|
16
|
+
*
|
|
17
|
+
* Espejo de `backend/utils/binary.js`: mismas constantes, misma heurística. Son
|
|
18
|
+
* dos paquetes sin dependencia entre sí (el CLI se publica solo y no tiene un
|
|
19
|
+
* solo `dependencies`), así que la duplicación es deliberada — pero si cambia
|
|
20
|
+
* una tiene que cambiar la otra, o un archivo se clasifica de un modo en el
|
|
21
|
+
* cliente y de otro en el servidor.
|
|
10
22
|
*/
|
|
11
23
|
|
|
12
24
|
// Espejo de `backend/utils/filenames.js`: lista negra, no lista blanca. El
|
|
@@ -19,7 +31,9 @@ const ILLEGAL_CHARS = /[<>:"/\\|?*]/;
|
|
|
19
31
|
const WIN_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
|
|
20
32
|
|
|
21
33
|
const MAX_NAME_LEN = 255;
|
|
22
|
-
|
|
34
|
+
// Espejo de MAX_FILE_BYTES en backend/utils/binary.js y del CHECK
|
|
35
|
+
// `file_size_valid` de la migración 014. Los tres son el mismo número.
|
|
36
|
+
const MAX_FILE_BYTES = 52428800; // 50 MB, igual que el backend
|
|
23
37
|
const MAX_DEPTH = 10; // profundidad máxima de carpetas
|
|
24
38
|
|
|
25
39
|
// Extensiones típicamente binarias: pre-filtro rápido antes de leer bytes.
|
|
@@ -126,23 +140,58 @@ function looksBinary(buffer, sample = 8000) {
|
|
|
126
140
|
}
|
|
127
141
|
|
|
128
142
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
|
|
143
|
+
* Mapa mínimo extensión -> mime. El backend vuelve a adivinar por su cuenta con
|
|
144
|
+
* `mime-types`, pero mandar el tipo desde acá es mejor: el cliente vio los bytes
|
|
145
|
+
* y el servidor, en una subida directa, sólo ve el nombre.
|
|
146
|
+
*/
|
|
147
|
+
const MIME_BY_EXT = {
|
|
148
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
149
|
+
webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', ico: 'image/x-icon',
|
|
150
|
+
tiff: 'image/tiff', tif: 'image/tiff', heic: 'image/heic',
|
|
151
|
+
pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
|
|
152
|
+
tar: 'application/x-tar', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
|
|
153
|
+
mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
|
|
154
|
+
ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', woff2: 'font/woff2',
|
|
155
|
+
doc: 'application/msword',
|
|
156
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
157
|
+
xls: 'application/vnd.ms-excel',
|
|
158
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
159
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
160
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Tipo de contenido de un binario. `octet-stream` cuando no se sabe. */
|
|
164
|
+
function binaryMimeType(name) {
|
|
165
|
+
return MIME_BY_EXT[extensionOf(name)] || 'application/octet-stream';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Clasifica el contenido de un archivo. Recibe el nombre (para la extensión) y
|
|
170
|
+
* el Buffer con su contenido. Devuelve:
|
|
171
|
+
* { isText: true, content } -> sube por JSON (batch)
|
|
172
|
+
* { isText: false, isBinary: true, buffer, mimeType }
|
|
173
|
+
* -> sube por URL firmada (direct)
|
|
174
|
+
* { isText: false, isBinary: false, reason } -> saltar con aviso
|
|
175
|
+
*
|
|
176
|
+
* El techo de tamaño se comprueba PRIMERO, antes de decidir texto o binario: un
|
|
177
|
+
* archivo demasiado grande se saltea sea lo que sea, y ponerlo después haría que
|
|
178
|
+
* el mensaje dijera "binario" cuando el problema es el tamaño.
|
|
133
179
|
*/
|
|
134
180
|
function classifyContent(name, buffer) {
|
|
135
|
-
const ext = extensionOf(name);
|
|
136
|
-
if (ext && BINARY_EXTENSIONS.has(ext)) {
|
|
137
|
-
return { isText: false, reason: `extensión binaria .${ext}` };
|
|
138
|
-
}
|
|
139
|
-
if (looksBinary(buffer)) {
|
|
140
|
-
return { isText: false, reason: 'contenido binario (bytes no imprimibles)' };
|
|
141
|
-
}
|
|
142
181
|
if (buffer.length > MAX_FILE_BYTES) {
|
|
143
|
-
return {
|
|
182
|
+
return {
|
|
183
|
+
isText: false,
|
|
184
|
+
isBinary: false,
|
|
185
|
+
reason: `supera ${MAX_FILE_BYTES / (1024 * 1024)} MB (${buffer.length} bytes)`,
|
|
186
|
+
};
|
|
144
187
|
}
|
|
145
|
-
|
|
188
|
+
|
|
189
|
+
const ext = extensionOf(name);
|
|
190
|
+
if ((ext && BINARY_EXTENSIONS.has(ext)) || looksBinary(buffer)) {
|
|
191
|
+
return { isText: false, isBinary: true, buffer, mimeType: binaryMimeType(name) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { isText: true, isBinary: false, content: buffer.toString('utf8') };
|
|
146
195
|
}
|
|
147
196
|
|
|
148
197
|
module.exports = {
|
|
@@ -157,5 +206,7 @@ module.exports = {
|
|
|
157
206
|
validatePath,
|
|
158
207
|
looksBinary,
|
|
159
208
|
classifyContent,
|
|
209
|
+
binaryMimeType,
|
|
210
|
+
MIME_BY_EXT,
|
|
160
211
|
extensionOf,
|
|
161
212
|
};
|