Skip to content

Commit 563e80b

Browse files
committed
1 parent 4e93e36 commit 563e80b

32 files changed

Lines changed: 1790 additions & 1225 deletions

services/IOUtils.js

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,47 @@ async function getFolderSize(folderPath) {
159159
}
160160
}
161161

162+
/**
163+
* The size of a whole app volume's directory tree, in bytes. `du` walks it in
164+
* one process with bounded memory; getFolderSize recurses in-process and fans
165+
* out a Promise per entry at every level, which is fine for the handful of
166+
* entries a folder listing shows and unbounded on an app's real data.
167+
*
168+
* Null rather than false when the size cannot be established: zero is a real
169+
* answer for an empty directory, and a falsy sentinel makes the two
170+
* indistinguishable at every call site that tests the result for truth.
171+
*
172+
* @param {string} dirPath - The path of the directory to measure.
173+
* @returns {Promise<number|null>} - Size in bytes, or null if it could not be measured.
174+
*/
175+
async function getDirectorySizeBytes(dirPath) {
176+
try {
177+
// Without -s, du reports each directory as it walks it and its own total
178+
// last, so the answer is unchanged while the walk becomes observable - which
179+
// is what an idle limit needs to mean anything. argv, no shell, for the same
180+
// reason as the tar calls.
181+
let total = null;
182+
const result = await serviceHelper.runStreamingCommand('du', {
183+
runAsRoot: true,
184+
params: ['-b', dirPath],
185+
idleTimeout: 5 * 60 * 1000,
186+
onLine: (line) => {
187+
const value = Number.parseInt(line.split(/\s+/)[0], 10);
188+
if (Number.isFinite(value)) total = value;
189+
},
190+
});
191+
if (result.error) {
192+
const message = (result.stderr || result.error.message || '').replace(/\n/g, ' ').trim();
193+
log.error(`Error measuring directory ${dirPath}: ${message}`);
194+
return null;
195+
}
196+
return total;
197+
} catch (error) {
198+
log.error(`Error measuring directory ${dirPath}: ${error.message}`);
199+
return null;
200+
}
201+
}
202+
162203
/**
163204
* Retrieves the size of the file at the specified path and formats it with an optional multiplier and decimal places.
164205
*
@@ -208,7 +249,11 @@ async function getRemoteFileSize(fileurl, multiplier, decimal, number = false) {
208249
* @param {string} multiplier - Unit multiplier for displaying sizes (B, KB, MB, GB).
209250
* @param {number} decimal - Number of decimal places for precision.
210251
* @param {string} fields - Optional comma-separated list of fields to include in the response. Possible fields: 'mount', 'size', 'used', 'available', 'capacity', 'filesystem'.
211-
* @returns {Array|boolean} - Array of objects containing volume information for the specified component, or false if no matching mount is found.
252+
* @returns {Promise<Array|null>} - Array of objects containing volume information for the
253+
* specified component, or null when no matching mount is found. df only reports
254+
* MOUNTED filesystems, so null is the answer for an unmounted volume as well as an
255+
* unknown one - callers must treat it as "this component's data is not reachable",
256+
* never as "empty".
212257
*/
213258
async function getVolumeInfo(appname, component, multiplier, decimal, fields) {
214259
try {
@@ -255,7 +300,7 @@ async function getVolumeInfo(appname, component, multiplier, decimal, fields) {
255300
}).filter((entry) => Object.keys(entry).length > 0);
256301
} catch (error) {
257302
log.error(error);
258-
return false;
303+
return null;
259304
}
260305
}
261306

@@ -416,6 +461,75 @@ async function untarFile(extractPath, tarFilePath) {
416461
}
417462
}
418463

464+
/**
465+
* Read a gzipped tar without extracting it. One decompression pass, nothing
466+
* written to disk and no space consumed, establishing that the archive is
467+
* complete and readable BEFORE anything is deleted to make room for its
468+
* contents, and yielding the numbers needed to decide whether those contents
469+
* will fit.
470+
*
471+
* The whole stream has to be inflated: gzip's CRC is in the trailing bytes, so
472+
* a truncated or corrupt archive cannot be recognised any other way, and the
473+
* ISIZE field beside it wraps at 4 GiB - useless on exactly the archives where
474+
* the size answer matters. The listing is counted as it arrives and never held,
475+
* so an archive of any member count costs the same to read.
476+
*
477+
* @param {string} tarFilePath - The path of the tarball (tar.gz) file to read.
478+
* @returns {Promise<{status: boolean, entries?: number, bytes?: number, error?: string}>}
479+
* entries is the member count; bytes is their total uncompressed size.
480+
*/
481+
async function inspectTarGz(tarFilePath) {
482+
try {
483+
let entries = 0;
484+
let bytes = 0;
485+
let sized = 0;
486+
487+
// argv, and no shell: root is the only reason this is a child process, and
488+
// a path reaches tar as an argument rather than as anything parsed.
489+
//
490+
// `sized` counts the members whose size column actually parsed as a number,
491+
// which is what separates a differently-shaped listing from an archive whose
492+
// members are all genuinely zero length.
493+
const result = await serviceHelper.runStreamingCommand('tar', {
494+
runAsRoot: true,
495+
params: ['-tzvf', tarFilePath],
496+
// Bounded by work rather than by size. These archives are the largest
497+
// thing the node handles, and a total limit can only kill the ones that
498+
// are merely big - which this then reports to an operator as their backup
499+
// being unreadable. Every line of the listing is proof of progress, so
500+
// silence this long is a read that has stalled.
501+
idleTimeout: 5 * 60 * 1000,
502+
onLine: (line) => {
503+
entries += 1;
504+
const size = line.split(/\s+/)[2];
505+
if (/^[0-9]+$/.test(size)) {
506+
sized += 1;
507+
bytes += Number(size);
508+
}
509+
},
510+
});
511+
512+
if (result.error) {
513+
const message = (result.stderr || result.error.message || '').replace(/\n/g, ' ').trim();
514+
log.error(`Error reading archive: ${message}`);
515+
return { status: false, error: message };
516+
}
517+
// The size column is the third field of GNU tar's verbose listing. If no
518+
// member's third field parsed as a number the listing is a different tar's
519+
// column layout, and reporting its total as zero would walk an unmeasured
520+
// archive through the free-space check. Testing the total rather than the
521+
// parse would also condemn an archive whose members are all genuinely empty,
522+
// which is a real thing to restore.
523+
if (entries > 0 && sized === 0) {
524+
return { status: false, error: 'archive listing not in the expected format' };
525+
}
526+
return { status: true, entries, bytes };
527+
} catch (error) {
528+
log.error('Error reading archive:', error);
529+
return { status: false, error: error.message };
530+
}
531+
}
532+
419533
/**
420534
* Creates a tarball (tar.gz) archive from the specified source directory.
421535
*
@@ -485,7 +599,9 @@ module.exports = {
485599
convertFileSize,
486600
downloadFileFromUrl,
487601
untarFile,
602+
inspectTarGz,
488603
createTarGz,
489604
removeDirectory,
490605
getFolderSize,
606+
getDirectorySizeBytes,
491607
};

services/analyticsService.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ function analyticsMiddleware(req, res, next) {
176176
return;
177177
}
178178

179-
const zelidauth = req.headers.zelidauth;
179+
const { zelidauth } = req.headers;
180180
if (!zelidauth) {
181181
next();
182182
return;

0 commit comments

Comments
 (0)