@@ -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 */
213258async 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} ;
0 commit comments