Skip to content

Commit 1bfb84b

Browse files
Merge pull request #208 from refactorfirst/#207-suppress-cli-creation
#207 Suppress CLI creation
2 parents e47a887 + 62a8d39 commit 1bfb84b

2 files changed

Lines changed: 146 additions & 60 deletions

File tree

‎pom.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
<!--<module>refactor-first-gradle-plugin</module>-->
7878
<module>coverage</module>
7979
<module>report</module>
80-
<module>cli</module>
80+
<!--<module>cli</module>-->
8181
</modules>
8282

8383
<dependencyManagement>

‎report/src/main/java/org/hjug/refactorfirst/report/ReportWriter.java‎

Lines changed: 145 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)