Skip to content

Commit 5375243

Browse files
adamrtalbotewels
authored andcommitted
Fix listFiles() and listDirectory() to follow symbolic links (#6808)
Signed-off-by: adamrtalbot <12817534+adamrtalbot@users.noreply.github.com>
1 parent fa2431f commit 5375243

File tree

6 files changed

+197
-28
lines changed

6 files changed

+197
-28
lines changed

modules/nextflow/src/main/groovy/nextflow/extension/PublishOp.groovy

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +66,38 @@ class PublishOp {
6666
if( opts.pathResolver instanceof Closure )
6767
this.pathResolver = opts.pathResolver as Closure
6868
if( opts.index )
69-
this.indexOpts = new IndexOpts(session.outputDir, opts.index as Map)
69+
this.indexOpts = new IndexOpts(opts.index as Map)
7070
}
7171

7272
DataflowVariable apply() {
7373
final events = new HashMap(2)
74-
events.onNext = this.&onNext
75-
events.onComplete = this.&onComplete
74+
events.onNext = { value ->
75+
safeExecute { onNext(value) }
76+
}
77+
events.onComplete = {
78+
safeExecute { onComplete() }
79+
}
7680
DataflowHelper.subscribeImpl(source, events)
7781
this.target = new DataflowVariable()
7882
return target
7983
}
8084

85+
/**
86+
* Perform an action. If an exception is raised, bind the
87+
* excpetion to the target and don't perform any more actions.
88+
*
89+
* @param action
90+
*/
91+
private void safeExecute(Runnable action) {
92+
if( target.isError() )
93+
return
94+
try {
95+
action.run()
96+
} catch( Throwable e ) {
97+
target.bindError(e)
98+
}
99+
}
100+
81101
/**
82102
* For each incoming value, perform the following:
83103
*
@@ -151,7 +171,8 @@ class PublishOp {
151171
if( resolvedPath instanceof CharSequence )
152172
return outputDir.resolve(resolvedPath.toString())
153173

154-
throw new ScriptRuntimeException("Invalid output `path` directive -- it should either return a string or use the `>>` operator to publish files")
174+
final invalid = mapping ?: resolvedPath
175+
throw new ScriptRuntimeException("Invalid `path` directive for workflow output '${name}' -- expected a string or publish statements, but received: ${invalid} [${invalid.class.simpleName}]")
155176
}
156177

157178
private class PublishDsl {
@@ -165,12 +186,12 @@ class PublishOp {
165186
}
166187
else if( source instanceof Collection<Path> ) {
167188
if( !target.endsWith('/') )
168-
throw new ScriptRuntimeException("Invalid publish target '${target}' -- should be a directory (end with a `/`) when publishing a collection of files")
189+
throw new ScriptRuntimeException("Invalid publish target '${target}' for workflow output '${name}' -- should be a directory (end with a `/`) when publishing a collection of files")
169190
for( final path : source )
170191
publish0(path, target)
171192
}
172193
else {
173-
throw new ScriptRuntimeException("Publish source should be a file or collection of files, but received a ${source.class.name}")
194+
throw new ScriptRuntimeException("Invalid publish source for workflow output '${name}' -- expected a file or collection of files, but received: ${source} [${source.class.simpleName}]")
174195
}
175196
}
176197

@@ -196,14 +217,16 @@ class PublishOp {
196217
* Once all channel values have been published, publish the final
197218
* workflow output and index file (if enabled).
198219
*/
199-
protected void onComplete(nope) {
220+
protected void onComplete() {
200221
// publish individual record if source is a value channel
201222
final outputValue = CH.isValue(source)
202223
? publishedValues.first()
203224
: publishedValues
204225

205226
// publish workflow output
206-
final indexPath = indexOpts ? indexOpts.path : null
227+
final indexPath = indexOpts
228+
? session.outputDir.resolve(indexOpts.path)
229+
: null
207230
session.notifyWorkflowOutput(new WorkflowOutputEvent(name, outputValue, indexPath))
208231

209232
// write value to index file
@@ -220,7 +243,7 @@ class PublishOp {
220243
indexPath.text = DumpHelper.prettyPrintYaml(outputValue)
221244
}
222245
else {
223-
log.warn "Invalid extension '${ext}' for index file '${indexPath}' -- should be CSV, JSON, or YAML"
246+
throw new ScriptRuntimeException("Invalid extension '${ext}' for index file '${indexOpts.path}' -- should be CSV, JSON, or YAML")
224247
}
225248
session.notifyFilePublish(new FilePublishEvent(null, indexPath, publishOpts.labels as List))
226249
}
@@ -292,7 +315,7 @@ class PublishOp {
292315
}
293316
}
294317

295-
throw new IllegalArgumentException("Index file record must be a list, map, or file: ${value} [${value.class.simpleName}]")
318+
throw new ScriptRuntimeException("Invalid value for workflow output '${name}' -- expected a list, map, or file, but received: ${value} [${value.class.simpleName}]")
296319
}
297320

298321
/**
@@ -359,13 +382,12 @@ class PublishOp {
359382
}
360383

361384
static class IndexOpts {
362-
Path path
385+
String path
363386
def /* boolean | List<String> */ header = false
364387
String sep = ','
365388

366-
IndexOpts(Path targetDir, Map opts) {
367-
this.path = targetDir.resolve(opts.path as String)
368-
389+
IndexOpts(Map opts) {
390+
this.path = opts.path as String
369391
if( opts.header != null )
370392
this.header = opts.header
371393
if( opts.sep )

modules/nextflow/src/main/groovy/nextflow/trace/AnsiLogObserver.groovy

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -416,24 +416,14 @@ class AnsiLogObserver implements TraceObserverV2 {
416416
private final static Pattern TAG_REGEX = ~/ \((.+)\)( *)$/
417417
private final static Pattern LBL_REPLACE = ~/ \(.+\) *$/
418418

419-
// OSC 8 hyperlink escape sequences
420-
// Format: ESC ] 8 ; ; URL ST TEXT ESC ] 8 ; ; ST
421-
// Using BEL (\007) as String Terminator for better compatibility
419+
// OSC 8 hyperlink escape sequences (using BEL as String Terminator)
422420
private final static String HYPERLINK_START = '\033]8;;'
423421
private final static String HYPERLINK_SEP = '\007'
424422
private final static String HYPERLINK_END = '\033]8;;\007'
425423

426-
/**
427-
* Wraps text in an OSC 8 terminal hyperlink escape sequence.
428-
*
429-
* @param text The visible text to display
430-
* @param url The URL to link to
431-
* @return The text wrapped in hyperlink escape sequences
432-
*/
433424
protected static String hyperlink(String text, String url) {
434425
if( !url )
435426
return text
436-
// Add file:// prefix for local paths (toUriString returns raw path for local files)
437427
final href = url.startsWith('/') ? 'file://' + url : url
438428
return HYPERLINK_START + href + HYPERLINK_SEP + text + HYPERLINK_END
439429
}
@@ -464,8 +454,7 @@ class AnsiLogObserver implements TraceObserverV2 {
464454
// eg. 1 of 1
465455
final numbs = " ${(int)com} of ${(int)tot}".toString()
466456

467-
// Task hash, eg: [fa/71091a]
468-
// If workDir is available and cleanup is not enabled, make the hash a clickable hyperlink
457+
// Task hash - make clickable hyperlink to work dir when available (and cleanup not enabled)
469458
final hashDisplay = (stats.workDir && !session.config.cleanup) ? hyperlink(hh, stats.workDir) : hh
470459
term.a(Attribute.INTENSITY_FAINT).a('[').reset()
471460
term.fg(Color.BLUE).a(hashDisplay).reset()

modules/nextflow/src/test/groovy/nextflow/script/OutputDslTest.groovy

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package nextflow.script
22

33
import java.nio.file.Files
4+
import java.nio.file.Path
45

56
import nextflow.Channel
67
import nextflow.Session
78
import nextflow.SysEnv
9+
import nextflow.exception.ScriptRuntimeException
810
import nextflow.trace.event.FilePublishEvent
911
import nextflow.trace.event.WorkflowOutputEvent
1012
import nextflow.trace.event.WorkflowPublishEvent
@@ -208,4 +210,104 @@ class OutputDslTest extends Specification {
208210
]
209211
}
210212

213+
def 'should report error for invalid path directive' () {
214+
when:
215+
def session = new Session(outputDir: Path.of('results'))
216+
217+
session.outputs.put('foo', Channel.of(1, 2, 3))
218+
219+
def dsl = new OutputDsl()
220+
dsl.declare('foo') {
221+
path { v -> 42 }
222+
}
223+
dsl.apply(session)
224+
session.fireDataflowNetwork()
225+
dsl.getOutput()
226+
227+
then:
228+
def e = thrown(ScriptRuntimeException)
229+
e.message.contains "Invalid `path` directive for workflow output 'foo'"
230+
e.message.contains "expected a string or publish statements, but received: 42 [Integer]"
231+
}
232+
233+
def 'should report error for invalid publish target' () {
234+
when:
235+
def session = new Session(outputDir: Path.of('results'))
236+
def file = Path.of('output.txt')
237+
238+
session.outputs.put('foo', Channel.of([file, file, file]))
239+
240+
def dsl = new OutputDsl()
241+
dsl.declare('foo') {
242+
path { files -> publish(files, 'foo.txt') }
243+
}
244+
dsl.apply(session)
245+
session.fireDataflowNetwork()
246+
dsl.getOutput()
247+
248+
then:
249+
def e = thrown(ScriptRuntimeException)
250+
e.message.contains "Invalid publish target 'foo.txt' for workflow output 'foo'"
251+
}
252+
253+
def 'should report error for invalid publish source' () {
254+
when:
255+
def session = new Session(outputDir: Path.of('results'))
256+
257+
session.outputs.put('foo', Channel.of(42))
258+
259+
def dsl = new OutputDsl()
260+
dsl.declare('foo') {
261+
path { v -> publish(v, 'foo') }
262+
}
263+
dsl.apply(session)
264+
session.fireDataflowNetwork()
265+
dsl.getOutput()
266+
267+
then:
268+
def e = thrown(ScriptRuntimeException)
269+
e.message.contains "Invalid publish source for workflow output 'foo'"
270+
e.message.contains "expected a file or collection of files, but received: 42 [Integer]"
271+
}
272+
273+
def 'should report error for invalid index file extension' () {
274+
when:
275+
def session = new Session(outputDir: Path.of('results'))
276+
277+
session.outputs.put('foo', Channel.empty())
278+
279+
def dsl = new OutputDsl()
280+
dsl.declare('foo') {
281+
index {
282+
path 'index.txt'
283+
}
284+
}
285+
dsl.apply(session)
286+
session.fireDataflowNetwork()
287+
dsl.getOutput()
288+
289+
then:
290+
def e = thrown(ScriptRuntimeException)
291+
e.message.contains "Invalid extension 'txt' for index file 'index.txt'"
292+
}
293+
294+
def 'should report error for invalid published value' () {
295+
when:
296+
def session = new Session(outputDir: Path.of('results'))
297+
298+
session.outputs.put('foo', Channel.of(42))
299+
300+
def dsl = new OutputDsl()
301+
dsl.declare('foo') {
302+
}
303+
dsl.apply(session)
304+
session.fireDataflowNetwork()
305+
dsl.getOutput()
306+
307+
then:
308+
def e = thrown(ScriptRuntimeException)
309+
e.message.contains "Invalid value for workflow output 'foo'"
310+
e.message.contains "expected a list, map, or file, but received: 42 [Integer]"
311+
}
312+
211313
}

modules/nextflow/src/test/groovy/nextflow/script/parser/v2/ScriptLoaderV2Test.groovy

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,4 +349,36 @@ class ScriptLoaderV2Test extends Dsl2Spec {
349349
parser.getResult().toString() == 'TUESDAY'
350350
}
351351

352+
def 'should report error for invalid publish statements in output block' () {
353+
given:
354+
def session = new Session()
355+
def parser = new ScriptLoaderV2(session)
356+
357+
def TEXT = '''
358+
workflow {
359+
main:
360+
ch = channel.empty()
361+
362+
publish:
363+
samples = ch
364+
}
365+
366+
output {
367+
samples {
368+
path { v ->
369+
if( true ) return 42
370+
v >> 'foo'
371+
}
372+
}
373+
}
374+
'''
375+
376+
when:
377+
parser.parse(TEXT)
378+
parser.runScript()
379+
then:
380+
def e = thrown(ScriptCompilationException)
381+
e.cause.message.contains 'Publish statements cannot be mixed with other statements in a dynamic publish path'
382+
}
383+
352384
}

modules/nf-commons/src/main/nextflow/extension/FilesEx.groovy

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import java.nio.ByteBuffer
2222
import java.nio.channels.SeekableByteChannel
2323
import java.nio.file.FileAlreadyExistsException
2424
import java.nio.file.FileSystemException
25+
import java.nio.file.FileVisitOption
2526
import java.nio.file.FileVisitResult
2627
import java.nio.file.Files
2728
import java.nio.file.LinkOption
@@ -658,8 +659,9 @@ class FilesEx {
658659
return null
659660

660661
final result = []
662+
final opts = EnumSet.of(FileVisitOption.FOLLOW_LINKS)
661663

662-
Files.walkFileTree(self, new SimpleFileVisitor<Path>() {
664+
Files.walkFileTree(self, opts, Integer.MAX_VALUE, new SimpleFileVisitor<Path>() {
663665

664666
FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
665667
if( filter == null || invokeFilter(filter, file, attrs) )

modules/nf-commons/src/test/nextflow/extension/FilesExTest.groovy

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,28 @@ class FilesExTest extends Specification {
603603
sourceFolder.deleteDir()
604604
}
605605

606+
def 'should list files in symlinked directory' () {
607+
608+
given:
609+
def folder = Files.createTempDirectory('test')
610+
def realDir = folder.resolve('realDir')
611+
Files.createDirectories(realDir)
612+
realDir.resolve('file1.txt').text = 'Hello1'
613+
realDir.resolve('file2.txt').text = 'Hello2'
614+
def linkDir = folder.resolve('linkDir')
615+
Files.createSymbolicLink(linkDir, realDir)
616+
617+
when:
618+
def paths = linkDir.listFiles()
619+
620+
then:
621+
paths.size() == 2
622+
paths.collect { it.name }.sort() == ['file1.txt', 'file2.txt']
623+
624+
cleanup:
625+
folder?.deleteDir()
626+
}
627+
606628

607629
def testSetReadonly() {
608630

0 commit comments

Comments
 (0)