11import { createHash , randomBytes } from "node:crypto" ;
2- import { mkdir , readFile , writeFile } from "node:fs/promises" ;
2+ import { mkdir , readFile , realpath , writeFile } from "node:fs/promises" ;
33import { basename , dirname , extname , isAbsolute , join , resolve } from "node:path" ;
44import { Result , type Result as BetterResult } from "better-result" ;
55import { hashSource } from "./workflow-script.js" ;
@@ -11,6 +11,7 @@ import {
1111 WorkflowFileReadError ,
1212 WorkflowFileWriteError ,
1313} from "./workflow-errors.js" ;
14+ import { isPathInsideRoot } from "./roots.js" ;
1415
1516export class WorkflowPathError extends Error {
1617 constructor ( message : string ) {
@@ -97,12 +98,65 @@ export async function readWorkflowScriptFileResult(
9798 } ) ;
9899}
99100
101+ export async function readProjectWorkflowScriptFile ( input : {
102+ scriptPath : string ;
103+ workspaceRoot : string ;
104+ } ) : Promise < ResolvedWorkflowScript > {
105+ const result = await readProjectWorkflowScriptFileResult ( input ) ;
106+ if ( result . isErr ( ) ) throwPathCompatibilityError ( result . error ) ;
107+ return result . value ;
108+ }
109+
110+ /** Resolve an explicit nested script only inside `<project>/.devspace/workflows`. */
111+ export async function readProjectWorkflowScriptFileResult ( input : {
112+ scriptPath : string ;
113+ workspaceRoot : string ;
114+ } ) : Promise < BetterResult < ResolvedWorkflowScript , WorkflowFileResolveError > > {
115+ const projectWorkflowRoot = resolve ( input . workspaceRoot , ".devspace" , "workflows" ) ;
116+ const requestedPath = isAbsolute ( input . scriptPath )
117+ ? resolve ( input . scriptPath )
118+ : resolve ( projectWorkflowRoot , input . scriptPath ) ;
119+
120+ if ( ! isPathInsideRoot ( requestedPath , projectWorkflowRoot ) ) {
121+ return Result . err (
122+ new InvalidWorkflowInputError ( {
123+ code : "invalid_path" ,
124+ message : `Nested workflow script must be inside ${ projectWorkflowRoot } ` ,
125+ } ) ,
126+ ) ;
127+ }
128+
129+ let canonicalRoot : string ;
130+ let canonicalPath : string ;
131+ try {
132+ [ canonicalRoot , canonicalPath ] = await Promise . all ( [
133+ realpath ( projectWorkflowRoot ) ,
134+ realpath ( requestedPath ) ,
135+ ] ) ;
136+ } catch ( cause ) {
137+ return Result . err (
138+ isFileNotFound ( cause )
139+ ? new WorkflowFileNotFoundError ( requestedPath )
140+ : new WorkflowFileReadError ( requestedPath , cause ) ,
141+ ) ;
142+ }
143+
144+ if ( ! isPathInsideRoot ( canonicalPath , canonicalRoot ) ) {
145+ return Result . err (
146+ new InvalidWorkflowInputError ( {
147+ code : "invalid_path" ,
148+ message : `Nested workflow script resolves outside ${ projectWorkflowRoot } ` ,
149+ } ) ,
150+ ) ;
151+ }
152+ return readWorkflowScriptFileResult ( canonicalPath ) ;
153+ }
154+
100155/**
101156 * Resolve named workflow script.
102157 * Search order:
103158 * 1. `<cwd>/.devspace/workflows/<name>.js`
104- * 2. `<cwd>/workflows/<name>.js`
105- * 3. `<stateDir>/workflows/<name>.js` (if stateDir provided)
159+ * 2. `<stateDir>/workflows/<name>.js` (if stateDir provided)
106160 */
107161export async function resolveNamedWorkflowScript ( input : {
108162 name : string ;
@@ -130,7 +184,6 @@ export async function resolveNamedWorkflowScriptResult(input: {
130184 }
131185 const candidates = [
132186 join ( input . workspaceRoot , ".devspace" , "workflows" , `${ name } .js` ) ,
133- join ( input . workspaceRoot , "workflows" , `${ name } .js` ) ,
134187 ] ;
135188 if ( input . stateDir ) {
136189 candidates . push ( join ( input . stateDir , "workflows" , `${ name } .js` ) ) ;
0 commit comments