@@ -13,6 +13,7 @@ import { isRecord } from '../utils.js';
1313
1414import { parse as parseFrontmatter } from './frontmatter.js' ;
1515import { oraOptions } from './logger.js' ;
16+ import { decodeURILastSegment , isSafePathSegment , resolvePathWithinRoot } from './safePath.js' ;
1617
1718type ChangelogsRequestSchema = PageRequestSchema < 'changelogs' > ;
1819type CustomPagesRequestSchema = PageRequestSchema < 'custom_pages' > ;
@@ -47,7 +48,7 @@ interface FileMapEntry {
4748 * Scrub out unnecessary data from the API and simplify fields.
4849 *
4950 */
50- function scrub ( data : GeneralRequestSchema ) : Record < string , unknown > | undefined {
51+ function scrub ( data : GeneralRequestSchema ) : Record < string , unknown > | null | undefined {
5152 const denylist = new Set ( [ 'api' , 'href' , 'links' , 'project' , 'renderable' , 'updated_at' , 'uri' ] ) ;
5253
5354 /** API defaults (to ensure we omit these when they're unchanged) */
@@ -79,10 +80,14 @@ function scrub(data: GeneralRequestSchema): Record<string, unknown> | undefined
7980
8081 scrubbed [ key ] = filtered ;
8182 } else if ( key === 'category' && isRecord ( value ) && typeof value . uri === 'string' ) {
82- const name = decodeURIComponent ( value . uri . split ( '/' ) . pop ( ) || '' ) ;
83+ const name = decodeURILastSegment ( value . uri ) ;
84+ if ( name === null ) return null ;
85+
8386 scrubbed [ key ] = { uri : name } ;
8487 } else if ( key === 'parent' && isRecord ( value ) && typeof value . uri === 'string' ) {
85- const slugPart = decodeURIComponent ( value . uri . split ( '/' ) . pop ( ) || '' ) ;
88+ const slugPart = decodeURILastSegment ( value . uri ) ;
89+ if ( slugPart === null ) return null ;
90+
8691 scrubbed [ key ] = { uri : slugPart } ;
8792 } else if ( ! ( key in DEFAULTS && value === DEFAULTS [ key as keyof typeof DEFAULTS ] ) ) {
8893 scrubbed [ key ] = value ;
@@ -121,7 +126,7 @@ function buildFileMap(this: APIv2PageExportCommands, files: string[]): Map<strin
121126 }
122127 } catch ( err ) {
123128 const message = err instanceof Error ? err . message : String ( err ) ;
124- this . error ( `Error parsing frontmatter in ${ filePath } : ${ message } ` ) ;
129+ throw new Error ( `Error parsing frontmatter in ${ filePath } : ${ message } ` , { cause : err } ) ;
125130 }
126131
127132 if ( frontmatter ) {
@@ -159,14 +164,13 @@ function buildPath(
159164 visited : Set < string > = new Set ( ) ,
160165) : string | null {
161166 if ( visited . has ( slug ) ) {
162- this . error ( `Circular reference detected for slug: ${ slug } ` ) ;
167+ throw new Error ( `Circular reference detected for slug: ${ slug } ` ) ;
163168 }
164169 visited . add ( slug ) ;
165170
166171 const fileInfo = fileMap . get ( slug ) ;
167172 if ( ! fileInfo ) {
168- this . error ( `File not found for slug: ${ slug } ` ) ;
169- return null ;
173+ throw new Error ( `File not found for slug: ${ slug } ` ) ;
170174 }
171175
172176 const parts : string [ ] = [ ] ;
@@ -230,7 +234,11 @@ function restructureFiles(this: APIv2PageExportCommands, tempFolder: string, fin
230234 this . debug ( 'Copying files to final structure:' ) ;
231235
232236 for ( const r of results ) {
233- const dest = path . join ( finalFolder , r . newPath ) ;
237+ const dest = resolvePathWithinRoot ( finalFolder , r . newPath ) ;
238+ if ( ! dest ) {
239+ throw new Error ( `Refusing to write outside export directory: ${ r . newPath } ` ) ;
240+ }
241+
234242 fs . mkdirSync ( path . dirname ( dest ) , { recursive : true } ) ;
235243 fs . copyFileSync ( r . oldPath , dest ) ;
236244 }
@@ -329,12 +337,35 @@ export async function exportDocs(this: APIv2PageExportCommands): Promise<FullExp
329337 parentCounts [ parentKey ] += 1 ;
330338 }
331339
332- const frontmatter = scrub ( output ) ;
333- const yamlFront = dumpYAML ( frontmatter , { sortKeys : true } ) ;
334- const md = `---\n${ yamlFront } ---\n${ body } ` ;
335-
336- fs . writeFileSync ( path . join ( tempFolder , `${ output . slug } .md` ) , md , { encoding : 'utf-8' } ) ;
337- downloads . completed . push ( output ) ;
340+ const pageSlug = output . slug ;
341+ if ( ! pageSlug || ! isSafePathSegment ( pageSlug ) ) {
342+ this . warn ( `Skipping page "${ String ( pageSlug ) } ": slug is invalid.` ) ;
343+ this . debug (
344+ `"${ String ( pageSlug ) } " contains directory separators and is not safe to use within the filesystem.` ,
345+ ) ;
346+ downloads . failed . push ( String ( pageSlug ) ) ;
347+ } else {
348+ const frontmatter = scrub ( output ) ;
349+ if ( ! frontmatter ) {
350+ this . warn ( `Skipping page "${ pageSlug } ": category or parent URI in API response is invalid.` ) ;
351+ this . debug (
352+ `"${ String ( pageSlug ) } " is invalid because its category or parent URI contains directory separators and is not safe to use within the filesystem.` ,
353+ ) ;
354+ downloads . failed . push ( pageSlug ) ;
355+ } else {
356+ const yamlFront = dumpYAML ( frontmatter , { sortKeys : true } ) ;
357+ const md = `---\n${ yamlFront } ---\n${ body } ` ;
358+ const tempPath = resolvePathWithinRoot ( tempFolder , `${ pageSlug } .md` ) ;
359+
360+ if ( ! tempPath ) {
361+ this . warn ( `Skipping page "${ pageSlug } ": refused to write outside the temporary export folder.` ) ;
362+ downloads . failed . push ( pageSlug ) ;
363+ } else {
364+ fs . writeFileSync ( tempPath , md , { encoding : 'utf-8' } ) ;
365+ downloads . completed . push ( output ) ;
366+ }
367+ }
368+ }
338369 }
339370 }
340371 } finally {
0 commit comments