@@ -52,17 +52,14 @@ public static void writeReportToDisk(
5252 final String reportOutputDirectory , final String filename , final String string ) {
5353 Path outputDirectory = Path .of (reportOutputDirectory ).toAbsolutePath ().normalize ();
5454 Path reportName = validateFilename (filename );
55- List <DirectoryStream <Path >> openedDirectories = new ArrayList <>();
5655
5756 try {
58- SecureDirectoryStream < Path > outputStream = openSecureDirectoryPath (outputDirectory , openedDirectories );
59- writeAtomically (outputStream , reportName , string );
57+ SecureDirectoryOps ops = SecureDirectoryOps . create (outputDirectory );
58+ ops . writeAtomically (reportName , string );
6059 log .info ("Done! View the report at {}" , outputDirectory .resolve (reportName ));
6160 } catch (IOException | UnsupportedOperationException e ) {
6261 log .error ("Unable to write report {}" , outputDirectory .resolve (reportName ), e );
6362 throw new ReportWriteException ("Unable to write report " + outputDirectory .resolve (reportName ), e );
64- } finally {
65- closeDirectories (openedDirectories );
6663 }
6764 }
6865
@@ -80,73 +77,162 @@ private static Path validateFilename(String filename) {
8077 return reportName ;
8178 }
8279
83- private static SecureDirectoryStream <Path > openSecureDirectoryPath (
84- Path outputDirectory , List <DirectoryStream <Path >> openedDirectories ) throws IOException {
85- Path root = outputDirectory .getRoot ();
86- if (root == null ) {
87- throw new IOException ("Report output directory has no filesystem root: " + outputDirectory );
80+ private interface SecureDirectoryOps {
81+ static SecureDirectoryOps create (Path outputDirectory ) throws IOException {
82+ // Try to use secure directory streams (Unix-like)
83+ Path current = outputDirectory ;
84+ while (current != null ) {
85+ if (Files .exists (current , NOFOLLOW_LINKS ) && !Files .isSymbolicLink (current )) {
86+ try (DirectoryStream <Path > stream = Files .newDirectoryStream (current )) {
87+ if (stream instanceof SecureDirectoryStream <?>) {
88+ return new SecureDirectoryOpsImpl (current , outputDirectory );
89+ }
90+ } catch (IOException | UnsupportedOperationException ignored ) {
91+ }
92+ }
93+ current = current .getParent ();
94+ }
95+ // Fallback for Windows
96+ return new FallbackDirectoryOps (outputDirectory );
8897 }
8998
90- DirectoryStream <Path > rootStream = Files .newDirectoryStream (root );
91- openedDirectories .add (rootStream );
92- SecureDirectoryStream <Path > current = asSecureDirectoryStream (rootStream , root );
93- Path currentPath = root ;
99+ void writeAtomically (Path reportName , String content ) throws IOException ;
100+ }
101+
102+ private static final class SecureDirectoryOpsImpl implements SecureDirectoryOps {
103+ private final Path startPath ;
104+ private final Path outputDirectory ;
105+ private final List <DirectoryStream <Path >> openedDirectories = new ArrayList <>();
106+
107+ SecureDirectoryOpsImpl (Path startPath , Path outputDirectory ) {
108+ this .startPath = startPath ;
109+ this .outputDirectory = outputDirectory ;
110+ }
94111
95- for ( Path component : root . relativize ( outputDirectory )) {
96- SecureDirectoryStream < Path > child ;
112+ @ Override
113+ public void writeAtomically ( Path reportName , String content ) throws IOException {
97114 try {
98- child = current .newDirectoryStream (component , NOFOLLOW_LINKS );
99- } catch (NoSuchFileException e ) {
100- Path directoryToCreate = currentPath .resolve (component );
101- Files .createDirectory (directoryToCreate );
102- child = current .newDirectoryStream (component , NOFOLLOW_LINKS );
115+ SecureDirectoryStream <Path > current = openSecurePath ();
116+ writeAtomicallySecure (current , reportName , content );
117+ } finally {
118+ closeDirectories (openedDirectories );
103119 }
104- openedDirectories .add (child );
105- current = child ;
106- currentPath = currentPath .resolve (component );
107120 }
108- return current ;
109- }
110121
111- @ SuppressWarnings ("unchecked" )
112- private static SecureDirectoryStream <Path > asSecureDirectoryStream (DirectoryStream <Path > stream , Path directory ) {
113- if (!(stream instanceof SecureDirectoryStream <?>)) {
114- throw new UnsupportedOperationException (
115- "Secure directory operations are unavailable for report output: " + directory );
122+ private SecureDirectoryStream <Path > openSecurePath () throws IOException {
123+ DirectoryStream <Path > startStream = Files .newDirectoryStream (startPath );
124+ openedDirectories .add (startStream );
125+ SecureDirectoryStream <Path > current = asSecureDirectoryStream (startStream , startPath );
126+ Path currentPath = startPath ;
127+
128+ for (Path component : startPath .relativize (outputDirectory )) {
129+ if (component .toString ().isEmpty ()) {
130+ continue ;
131+ }
132+ SecureDirectoryStream <Path > child ;
133+ try {
134+ child = current .newDirectoryStream (component , NOFOLLOW_LINKS );
135+ } catch (NoSuchFileException e ) {
136+ Path directoryToCreate = currentPath .resolve (component );
137+ Files .createDirectory (directoryToCreate );
138+ child = current .newDirectoryStream (component , NOFOLLOW_LINKS );
139+ }
140+ openedDirectories .add (child );
141+ current = child ;
142+ currentPath = currentPath .resolve (component );
143+ }
144+ return current ;
116145 }
117- return (SecureDirectoryStream <Path >) stream ;
118- }
119146
120- private static void writeAtomically (SecureDirectoryStream <Path > directory , Path reportName , String content )
121- throws IOException {
122- BasicFileAttributeView targetView =
123- directory .getFileAttributeView (reportName , BasicFileAttributeView .class , NOFOLLOW_LINKS );
124- try {
125- BasicFileAttributes attributes = targetView .readAttributes ();
126- if (attributes .isSymbolicLink () || attributes .isDirectory ()) {
127- throw new IOException ("Refusing to replace non-regular report path: " + reportName );
147+ private void writeAtomicallySecure (SecureDirectoryStream <Path > directory , Path reportName , String content )
148+ throws IOException {
149+ BasicFileAttributeView targetView =
150+ directory .getFileAttributeView (reportName , BasicFileAttributeView .class , NOFOLLOW_LINKS );
151+ try {
152+ BasicFileAttributes attributes = targetView .readAttributes ();
153+ if (attributes .isSymbolicLink () || attributes .isDirectory ()) {
154+ throw new IOException ("Refusing to replace non-regular report path: " + reportName );
155+ }
156+ } catch (NoSuchFileException ignored ) {
157+ }
158+
159+ Path temporaryName = Path .of ("." + reportName + "." + UUID .randomUUID () + ".tmp" );
160+ Set <OpenOption > options = Set .of (CREATE_NEW , WRITE , NOFOLLOW_LINKS );
161+ boolean moved = false ;
162+ try {
163+ try (SeekableByteChannel channel = directory .newByteChannel (temporaryName , options );
164+ BufferedWriter writer = new BufferedWriter (
165+ new OutputStreamWriter (Channels .newOutputStream (channel ), Charset .defaultCharset ()))) {
166+ writer .write (content );
167+ }
168+ directory .move (temporaryName , directory , reportName );
169+ moved = true ;
170+ } finally {
171+ if (!moved ) {
172+ try {
173+ directory .deleteFile (temporaryName );
174+ } catch (NoSuchFileException ignored ) {
175+ }
176+ }
128177 }
129- } catch (NoSuchFileException ignored ) {
130- // The normal first-write case.
131178 }
132179
133- Path temporaryName = Path .of ("." + reportName + "." + UUID .randomUUID () + ".tmp" );
134- Set <OpenOption > options = Set .of (CREATE_NEW , WRITE , NOFOLLOW_LINKS );
135- boolean moved = false ;
136- try {
137- try (SeekableByteChannel channel = directory .newByteChannel (temporaryName , options );
138- BufferedWriter writer = new BufferedWriter (
139- new OutputStreamWriter (Channels .newOutputStream (channel ), Charset .defaultCharset ()))) {
140- writer .write (content );
180+ @ SuppressWarnings ("unchecked" )
181+ private static SecureDirectoryStream <Path > asSecureDirectoryStream (
182+ DirectoryStream <Path > stream , Path directory ) {
183+ if (!(stream instanceof SecureDirectoryStream <?>)) {
184+ throw new UnsupportedOperationException (
185+ "Secure directory operations are unavailable for report output: " + directory );
141186 }
142- directory .move (temporaryName , directory , reportName );
143- moved = true ;
144- } finally {
145- if (!moved ) {
146- try {
147- directory .deleteFile (temporaryName );
148- } catch (NoSuchFileException ignored ) {
149- // Nothing to clean up.
187+ return (SecureDirectoryStream <Path >) stream ;
188+ }
189+ }
190+
191+ private static final class FallbackDirectoryOps implements SecureDirectoryOps {
192+ private final Path outputDirectory ;
193+
194+ FallbackDirectoryOps (Path outputDirectory ) {
195+ this .outputDirectory = outputDirectory ;
196+ }
197+
198+ @ Override
199+ public void writeAtomically (Path reportName , String content ) throws IOException {
200+ Path targetFile = outputDirectory .resolve (reportName );
201+ rejectExistingSymbolicLinkComponents (targetFile );
202+
203+ // Ensure parent directories exist
204+ Files .createDirectories (outputDirectory );
205+
206+ // Check if target exists and is a symlink or directory
207+ if (Files .exists (targetFile , NOFOLLOW_LINKS )) {
208+ BasicFileAttributes attrs = Files .readAttributes (targetFile , BasicFileAttributes .class , NOFOLLOW_LINKS );
209+ if (attrs .isSymbolicLink () || attrs .isDirectory ()) {
210+ throw new IOException ("Refusing to replace non-regular report path: " + reportName );
211+ }
212+ }
213+
214+ // Write to temporary file then atomically move
215+ Path temporaryName = outputDirectory .resolve ("." + reportName + "." + UUID .randomUUID () + ".tmp" );
216+ Set <OpenOption > options = Set .of (CREATE_NEW , WRITE );
217+ boolean moved = false ;
218+ try {
219+ try (SeekableByteChannel channel = Files .newByteChannel (temporaryName , options );
220+ BufferedWriter writer = new BufferedWriter (
221+ new OutputStreamWriter (Channels .newOutputStream (channel ), Charset .defaultCharset ()))) {
222+ writer .write (content );
223+ }
224+ // Verify temp file is not a symlink before move
225+ if (Files .isSymbolicLink (temporaryName )) {
226+ throw new IOException ("Temporary file is a symbolic link" );
227+ }
228+ Files .move (temporaryName , targetFile , java .nio .file .StandardCopyOption .ATOMIC_MOVE );
229+ moved = true ;
230+ } finally {
231+ if (!moved ) {
232+ try {
233+ Files .deleteIfExists (temporaryName );
234+ } catch (IOException ignored ) {
235+ }
150236 }
151237 }
152238 }
0 commit comments