add nextflow d30e48d

This commit is contained in:
2026-04-29 23:01:54 +02:00
parent d0b12d668d
commit 97cc9058d3
2840 changed files with 730250 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
apply plugin: 'groovy'
sourceSets {
main.java.srcDirs = []
main.groovy.srcDirs = ['src/main']
main.resources.srcDirs = ['src/resources']
test.groovy.srcDirs = ['src/test']
test.java.srcDirs = []
test.resources.srcDirs = []
}
dependencies {
api project(':nf-commons')
api "ch.qos.logback:logback-classic:1.5.26"
api "org.apache.groovy:groovy:4.0.31"
api "org.apache.groovy:groovy-nio:4.0.31"
api("com.esotericsoftware.kryo:kryo:2.24.0") { exclude group: 'com.esotericsoftware.minlog', module: 'minlog' }
/* testImplementation inherited from top gradle build file */
testImplementation "org.apache.groovy:groovy-json:4.0.31" // needed by wiremock
testImplementation ('org.wiremock:wiremock:3.13.1') { exclude module: 'groovy-all' }
testImplementation(testFixtures(project(":nextflow")))
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import groovy.transform.CompileStatic
/**
* Implements a {@link FilterInputStream} that checks the expected length of bytes have been
* read when closing the stream or throws an error otherwise
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class FixedInputStream extends FilterInputStream {
private final long length
private long bytesRead
FixedInputStream(InputStream inputStream, long len) {
super(inputStream)
this.length = len
}
@Override
int read() throws IOException {
final result = super.read()
if( result!=-1 )
bytesRead++
return result
}
@Override
int read(byte[] b, int off, int len) throws IOException {
final result = super.read(b, off, len)
if( result!=-1 )
bytesRead += result
return result
}
@Override
long skip(long n) throws IOException {
long skipped = super.skip(n)
bytesRead += skipped
return skipped
}
@Override
int available() throws IOException {
super.available()
}
@Override
void close() throws IOException {
if( bytesRead != length )
throw new IOException("Read data length does not match expected size - bytes read: ${bytesRead}; expected: ${length}")
super.close()
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import groovy.transform.CompileStatic
/**
* JSR-203 file system provider for FTP protocol
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class FtpFileSystemProvider extends XFileSystemProvider {
@Override
String getScheme() {
return 'ftp'
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import groovy.transform.CompileStatic
/**
* JSR-203 file system provider for HTTP protocol
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class HttpFileSystemProvider extends XFileSystemProvider {
private static final String SCHEME = 'http'
@Override
String getScheme() {
return SCHEME
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import groovy.transform.CompileStatic
/**
* JSR-203 file system provider for HTTPS protocol
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class HttpsFileSystemProvider extends XFileSystemProvider {
@Override
String getScheme() {
return 'https'
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
/**
* Implements a pluggable authentication provider for {@link XFileSystemProvider}
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
interface XAuthProvider {
/**
* Implementing class should check whenever accept and authorise the connection
*
* @param connection A {@link URLConnection} object instance to be authorised
* @return {@code true} if the connection object has been authorised or {@code false} otherwise
*/
boolean authorize( URLConnection connection )
boolean refreshToken( URLConnection connection )
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import groovy.transform.CompileStatic
/**
* Provides a pluggable authentication registry for {@link XFileSystemProvider}
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Singleton(strict = false)
@CompileStatic
class XAuthRegistry {
private List<XAuthProvider> providers = new ArrayList<>()
protected XAuthRegistry() {}
void register(XAuthProvider provider) {
providers.add(provider)
}
void unregister(XAuthProvider provider) {
final p = providers.indexOf(provider)
if( p!=-1 )
providers.remove(p)
}
boolean authorize(URLConnection connection) {
for( XAuthProvider it : providers ) {
if( it.authorize(connection) )
return true
}
return false
}
boolean refreshToken(URLConnection connection) {
for( XAuthProvider it : providers ) {
if( it.refreshToken(connection) )
return true
}
return false
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileTime
import groovy.transform.CompileStatic
import groovy.transform.PackageScope
/**
* Implements a {@link BasicFileAttributes} for http/ftp
* file system providers
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
* @author Emilio Palumbo <emilio.palumbo@crg.eu>
*/
@PackageScope
@CompileStatic
class XFileAttributes implements BasicFileAttributes {
private FileTime lastModifiedTime
private long size
XFileAttributes(FileTime lastModifiedTime, long size) {
this.lastModifiedTime = lastModifiedTime
this.size = size
}
@Override
FileTime lastModifiedTime() {
return lastModifiedTime
}
@Override
FileTime lastAccessTime() {
return null
}
@Override
FileTime creationTime() {
return null
}
@Override
boolean isRegularFile() {
return true
}
@Override
boolean isDirectory() {
return false
}
@Override
boolean isSymbolicLink() {
return false
}
@Override
boolean isOther() {
return false
}
@Override
long size() {
return size
}
@Override
Object fileKey() {
return null
}
}

View File

@@ -0,0 +1,133 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import groovy.transform.CompileStatic
import java.nio.file.FileStore
import java.nio.file.FileSystem
import java.nio.file.Path
import java.nio.file.PathMatcher
import java.nio.file.WatchService
import java.nio.file.attribute.UserPrincipalLookupService
import java.nio.file.spi.FileSystemProvider
import groovy.transform.PackageScope
/**
* Implements a read-only JSR-203 compliant file system for http/ftp protocols
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
* @author Emilio Palumbo <emilio.palumbo@crg.eu>
*/
@PackageScope
@CompileStatic
class XFileSystem extends FileSystem {
static private String PATH_SEPARATOR = '/'
private XFileSystemProvider provider
private URI base
/*
* Only needed to prevent serialization issues - see https://github.com/nextflow-io/nextflow/issues/5208
*/
protected XFileSystem(){}
XFileSystem(XFileSystemProvider provider, URI base) {
this.provider = provider
this.base = base
}
@Override
boolean equals( Object other ) {
if( this.class != other.class ) return false
final that = (XFileSystem)other
this.provider == that.provider && this.base == that.base
}
@Override
int hashCode() {
Objects.hash(provider,base)
}
@Override
FileSystemProvider provider() {
return provider
}
URI getBaseUri() {
return base
}
@Override
void close() throws IOException {
}
@Override
boolean isOpen() {
return true
}
@Override
boolean isReadOnly() {
return true
}
@Override
String getSeparator() {
return PATH_SEPARATOR
}
@Override
Iterable<Path> getRootDirectories() {
return null
}
@Override
Iterable<FileStore> getFileStores() {
return null
}
@Override
Set<String> supportedFileAttributeViews() {
return null
}
@Override
Path getPath(String first, String... more) {
return new XPath(this,first,more)
}
@Override
PathMatcher getPathMatcher(String syntaxAndPattern) {
return null
}
@Override
UserPrincipalLookupService getUserPrincipalLookupService() {
throw new UnsupportedOperationException('User Principal Lookup Service not supported')
}
@Override
WatchService newWatchService() throws IOException {
throw new UnsupportedOperationException('Watch Service not supported')
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import groovy.transform.CompileStatic
import nextflow.SysEnv
/**
* Hold HTTP/FTP virtual file system configuration
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class XFileSystemConfig {
static XFileSystemConfig instance = new XFileSystemConfig()
static final public String DEFAULT_RETRY_CODES = '404,410'
static final public int MAX_REDIRECT_HOPS = 5
static final public int DEFAULT_BACK_OFF_BASE = 3
static final public int DEFAULT_BACK_OFF_DELAY = 250
static final public int DEFAULT_MAX_ATTEMPTS = 3
private int maxAttempts = DEFAULT_MAX_ATTEMPTS
private int backOffBase = DEFAULT_BACK_OFF_BASE
private int backOffDelay = DEFAULT_BACK_OFF_DELAY
private List<Integer> retryCodes
{
maxAttempts = config('NXF_HTTPFS_MAX_ATTEMPTS', DEFAULT_MAX_ATTEMPTS) as Integer
backOffBase = config('NXF_HTTPFS_BACKOFF_BASE', DEFAULT_BACK_OFF_BASE) as Integer
backOffDelay = config('NXF_HTTPFS_DELAY', DEFAULT_BACK_OFF_DELAY) as Integer
retryCodes = config('NXF_HTTPFS_RETRY_CODES', DEFAULT_RETRY_CODES).tokenize(',').collect( val -> val as Integer )
}
static String config(String name, def defValue) {
return SysEnv.containsKey(name) ? SysEnv.get(name) : defValue.toString()
}
int maxAttempts() { maxAttempts }
int backOffBase() { backOffBase }
int backOffDelay() { backOffDelay }
List<Integer> retryCodes() { retryCodes }
static XFileSystemConfig config() { return instance }
}

View File

@@ -0,0 +1,502 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import nextflow.file.CopyMoveHelper
import static nextflow.file.http.XFileSystemConfig.*
import java.nio.ByteBuffer
import java.nio.channels.SeekableByteChannel
import java.nio.file.AccessDeniedException
import java.nio.file.AccessMode
import java.nio.file.CopyOption
import java.nio.file.DirectoryStream
import java.nio.file.FileStore
import java.nio.file.FileSystem
import java.nio.file.FileSystemNotFoundException
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.OpenOption
import java.nio.file.Path
import java.nio.file.ProviderMismatchException
import java.nio.file.StandardOpenOption
import java.nio.file.attribute.BasicFileAttributes
import java.nio.file.attribute.FileAttribute
import java.nio.file.attribute.FileAttributeView
import java.nio.file.attribute.FileTime
import java.nio.file.spi.FileSystemProvider
import java.text.SimpleDateFormat
import java.util.concurrent.TimeUnit
import groovy.transform.CompileStatic
import groovy.transform.PackageScope
import groovy.util.logging.Slf4j
import nextflow.SysEnv
import nextflow.extension.FilesEx
import nextflow.file.FileHelper
import nextflow.util.InsensitiveMap
import sun.net.www.protocol.ftp.FtpURLConnection
/**
* Implements a read-only JSR-203 compliant file system provider for http/ftp protocols
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
* @author Emilio Palumbo <emilio.palumbo@crg.eu>
*/
@Slf4j
@PackageScope
@CompileStatic
abstract class XFileSystemProvider extends FileSystemProvider {
private Map<URI, FileSystem> fileSystemMap = new LinkedHashMap<>(20)
private static final int[] REDIRECT_CODES = [301, 302, 307, 308]
protected static String config(String name, def defValue) {
return SysEnv.containsKey(name) ? SysEnv.get(name) : defValue.toString()
}
static private URI key(String s, String a) {
new URI("$s://$a")
}
static private URI key(URI uri) {
final base = uri.authority
int p = base.indexOf('@')
if( p==-1 )
return key(uri.scheme.toLowerCase(), base.toLowerCase())
else {
final user = base.substring(0,p)
final host = base.substring(p)
return key(uri.scheme.toLowerCase(), user + host.toLowerCase())
}
}
@Override
FileSystem newFileSystem(URI uri, Map<String, ?> env) throws IOException {
final scheme = uri.scheme.toLowerCase()
if( scheme != this.getScheme() )
throw new IllegalArgumentException("Not a valid ${getScheme().toUpperCase()} scheme: $scheme")
final base = key(uri)
if (fileSystemMap.containsKey(base))
throw new IllegalStateException("File system `$base` already exists")
return new XFileSystem(this, base)
}
/**
* Returns an existing {@code FileSystem} created by this provider.
*
* <p> This method returns a reference to a {@code FileSystem} that was
* created by invoking the {@link #newFileSystem(URI,Map) newFileSystem(URI,Map)}
* method. File systems created the {@link #newFileSystem(Path,Map)
* newFileSystem(Path,Map)} method are not returned by this method.
* The file system is identified by its {@code URI}. Its exact form
* is highly provider dependent. In the case of the default provider the URI's
* path component is {@code "/"} and the authority, query and fragment components
* are undefined (Undefined components are represented by {@code null}).
*
* @param uri
* URI reference
*
* @return The file system
*
* @throws IllegalArgumentException
* If the pre-conditions for the {@code uri} parameter aren't met
* @throws FileSystemNotFoundException
* If the file system does not exist
* @throws SecurityException
* If a security manager is installed and it denies an unspecified
* permission.
*/
@Override
FileSystem getFileSystem(URI uri) {
getFileSystem(uri,false)
}
FileSystem getFileSystem(URI uri, boolean canCreate) {
assert fileSystemMap != null
final scheme = uri.scheme.toLowerCase()
if( scheme != this.getScheme() )
throw new IllegalArgumentException("Not a valid ${getScheme().toUpperCase()} scheme: $scheme")
final key = key(uri)
if( !canCreate ) {
FileSystem result = fileSystemMap[key]
if( result==null )
throw new FileSystemNotFoundException("File system not found: $key")
return result
}
synchronized (fileSystemMap) {
FileSystem result = fileSystemMap[key]
if( result==null ) {
result = newFileSystem(uri,Collections.emptyMap())
fileSystemMap[key] = result
}
return result
}
}
@Override
Path getPath(URI uri) {
def path = uri.path
if( !path.contains('?') && uri.query )
path += '?' + uri.query
return getFileSystem(uri,true).getPath(path)
}
protected String auth(String userInfo) {
final String BEARER = 'x-oauth-bearer:'
int p = userInfo.indexOf(BEARER)
if( p!=-1 ) {
final token = userInfo.substring(BEARER.length())
return "Bearer $token"
}
else {
return "Basic ${userInfo.getBytes().encodeBase64()}"
}
}
protected URLConnection toConnection(Path path) {
final url = path.toUri().toURL()
log.trace "File remote URL: $url"
return toConnection0(url, 0)
}
protected URLConnection toConnection0(URL url, int attempt) {
final conn = url.openConnection()
conn.setRequestProperty("User-Agent", 'Nextflow/httpfs')
if( conn instanceof HttpURLConnection ) {
// by default HttpURLConnection does redirect only within the same host
// disable the built-in to implement custom redirection logic (see below)
conn.setInstanceFollowRedirects(false)
}
if( url.userInfo ) {
conn.setRequestProperty("Authorization", auth(url.userInfo));
}
else {
XAuthRegistry.instance.authorize(conn)
}
if ( conn instanceof HttpURLConnection && conn.getResponseCode() in REDIRECT_CODES && attempt < MAX_REDIRECT_HOPS ) {
final header = InsensitiveMap.of(conn.getHeaderFields())
final location = header.get("Location")?.get(0)
log.debug "Remote redirect location=$location; attempt=$attempt"
final newUrl = new URI(absLocation(location,url)).toURL()
if( url.protocol=='https' && newUrl.protocol=='http' )
throw new IOException("Refuse to follow redirection from HTTPS to HTTP (unsafe) URL - origin: $url - target: $newUrl")
return toConnection0(newUrl, attempt+1)
}
else if( conn instanceof HttpURLConnection && conn.getResponseCode() in config().retryCodes() && attempt < config().maxAttempts() ) {
final delay = (Math.pow(config().backOffBase(), attempt) as long) * config().backOffDelay()
log.debug "Got HTTP error=${conn.getResponseCode()} waiting for ${delay}ms (attempt=${attempt+1})"
Thread.sleep(delay)
return toConnection0(url, attempt+1)
}
else if( conn instanceof HttpURLConnection && conn.getResponseCode()==401 && attempt==0 ) {
if( XAuthRegistry.instance.refreshToken(conn) ) {
return toConnection0(url, attempt+1)
}
}
return conn
}
protected String absLocation(String location, URL target) {
assert location, "Missing location argument"
assert target, "Missing target URL argument"
final base = FileHelper.baseUrl(location)
if( base )
return location
if( !location.startsWith('/') )
location = '/' + location
return "${target.protocol}://${target.authority}$location"
}
@Override
SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs) throws IOException {
if (path.class != XPath)
throw new ProviderMismatchException()
if (options.size() > 0) {
for (OpenOption opt: options) {
// All OpenOption values except for APPEND and WRITE are allowed
if (opt == StandardOpenOption.APPEND || opt == StandardOpenOption.WRITE)
throw new UnsupportedOperationException("'$opt' not allowed");
}
}
final conn = toConnection(path)
final length = conn.getContentLengthLong()
final target = length>0
? new FixedInputStream(conn.getInputStream(),length)
: conn.getInputStream()
final stream = new BufferedInputStream(target)
new SeekableByteChannel() {
private long _position
@Override
int read(ByteBuffer buffer) throws IOException {
def data=0
int len=0
while( buffer.hasRemaining() && (data=stream.read())!=-1 ) {
buffer.put((byte)data)
len++
}
_position += len
return len ?: -1
}
@Override
int write(ByteBuffer src) throws IOException {
throw new UnsupportedOperationException("Write operation not supported")
}
@Override
long position() throws IOException {
return _position
}
@Override
SeekableByteChannel position(long newPosition) throws IOException {
throw new UnsupportedOperationException("Position operation not supported")
}
@Override
long size() throws IOException {
// this value is going to be used as the buffer size
// file related operation. See for example {@link Files#readAllBytes}
return 8192
}
@Override
SeekableByteChannel truncate(long unused) throws IOException {
throw new UnsupportedOperationException("Truncate operation not supported")
}
@Override
boolean isOpen() {
return true
}
@Override
void close() throws IOException {
stream.close()
}
}
}
/**
* Opens a file, returning an input stream to read from the file. This
* method works in exactly the manner specified by the {@link
* Files#newInputStream} method.
*
* <p> The default implementation of this method opens a channel to the file
* as if by invoking the {@link #newByteChannel} method and constructs a
* stream that reads bytes from the channel. This method should be overridden
* where appropriate.
*
* @param path
* the path to the file to open
* @param options
* options specifying how the file is opened
*
* @return a new input stream
*
* @throws IllegalArgumentException
* if an invalid combination of options is specified
* @throws UnsupportedOperationException
* if an unsupported option is specified
* @throws IOException
* if an I/O error occurs
* @throws SecurityException
* In the case of the default provider, and a security manager is
* installed, the {@link SecurityManager#checkRead(String) checkRead}
* method is invoked to check read access to the file.
*/
@Override
InputStream newInputStream(Path path, OpenOption... options)
throws IOException
{
if (path.class != XPath)
throw new ProviderMismatchException()
if (options.length > 0) {
for (OpenOption opt: options) {
// All OpenOption values except for APPEND and WRITE are allowed
if (opt == StandardOpenOption.APPEND ||
opt == StandardOpenOption.WRITE)
throw new UnsupportedOperationException("'$opt' not allowed");
}
}
final conn = toConnection(path)
final length = conn.getContentLengthLong()
// only apply the FixedInputStream check if staging files
return length>0 && CopyMoveHelper.IN_FOREIGN_COPY.get()
? new FixedInputStream(conn.getInputStream(), length)
: conn.getInputStream()
}
/**
* Opens or creates a file, returning an output stream that may be used to
* write bytes to the file. This method works in exactly the manner
* specified by the {@link Files#newOutputStream} method.
*
* <p> The default implementation of this method opens a channel to the file
* as if by invoking the {@link #newByteChannel} method and constructs a
* stream that writes bytes to the channel. This method should be overridden
* where appropriate.
*
* @param path
* the path to the file to open or create
* @param options
* options specifying how the file is opened
*
* @return a new output stream
*
* @throws IllegalArgumentException
* if {@code options} contains an invalid combination of options
* @throws UnsupportedOperationException
* if an unsupported option is specified
* @throws IOException
* if an I/O error occurs
* @throws SecurityException
* In the case of the default provider, and a security manager is
* installed, the {@link SecurityManager#checkWrite(String) checkWrite}
* method is invoked to check write access to the file. The {@link
* SecurityManager#checkDelete(String) checkDelete} method is
* invoked to check delete access if the file is opened with the
* {@code DELETE_ON_CLOSE} option.
*/
@Override
public OutputStream newOutputStream(Path path, OpenOption... options)
throws IOException
{
throw new UnsupportedOperationException("Write not supported by ${getScheme().toUpperCase()} file system provider")
}
@Override
DirectoryStream<Path> newDirectoryStream(Path dir, DirectoryStream.Filter<? super Path> filter) throws IOException {
throw new UnsupportedOperationException("Directory listing unsupported by ${getScheme().toUpperCase()} file system provider")
}
@Override
void createDirectory(Path dir, FileAttribute<?>... attrs) throws IOException {
throw new UnsupportedOperationException("Create directory not supported by ${getScheme().toUpperCase()} file system provider")
}
@Override
void delete(Path path) throws IOException {
throw new UnsupportedOperationException("Delete not supported by ${getScheme().toUpperCase()} file system provider")
}
@Override
void copy(Path source, Path target, CopyOption... options) throws IOException {
throw new UnsupportedOperationException("Copy not supported by ${getScheme().toUpperCase()} file system provider")
}
@Override
void move(Path source, Path target, CopyOption... options) throws IOException {
throw new UnsupportedOperationException("Move not supported by ${getScheme().toUpperCase()} file system provider")
}
@Override
boolean isSameFile(Path path, Path path2) throws IOException {
return path == path2
}
@Override
boolean isHidden(Path path) throws IOException {
return path.getFileName().startsWith('.')
}
@Override
FileStore getFileStore(Path path) throws IOException {
throw new UnsupportedOperationException("File store not supported by ${getScheme().toUpperCase()} file system provider")
}
@Override
void checkAccess(Path path, AccessMode... modes) throws IOException {
readAttributes(path, XFileAttributes)
for( AccessMode m : modes ) {
if( m == AccessMode.WRITE )
throw new AccessDeniedException("Write mode not supported")
if( m == AccessMode.EXECUTE )
throw new AccessDeniedException("Execute mode not supported")
}
}
@Override
def <V extends FileAttributeView> V getFileAttributeView(Path path, Class<V> type, LinkOption... options) {
return null
}
@Override
def <A extends BasicFileAttributes> A readAttributes(Path path, Class<A> type, LinkOption... options) throws IOException {
if ( type == BasicFileAttributes || type == XFileAttributes) {
def p = (XPath) path
def attrs = (A)readHttpAttributes(p)
if (attrs == null) {
throw new IOException("Unable to access path: ${FilesEx.toUriString(p)}")
}
return attrs
}
throw new UnsupportedOperationException("Not a valid ${getScheme().toUpperCase()} file attribute type: $type")
}
@Override
Map<String, Object> readAttributes(Path path, String attributes, LinkOption... options) throws IOException {
throw new UnsupportedOperationException("Read file attributes not supported by ${getScheme().toUpperCase()} file system provider")
}
@Override
void setAttribute(Path path, String attribute, Object value, LinkOption... options) throws IOException {
throw new UnsupportedOperationException("Set file attributes not supported by ${getScheme().toUpperCase()} file system provider")
}
protected XFileAttributes readHttpAttributes(XPath path) {
final conn = toConnection(path)
if( conn instanceof FtpURLConnection ) {
return new XFileAttributes(null,-1)
}
if ( conn instanceof HttpURLConnection && conn.getResponseCode() in [200, 301, 302, 307, 308]) {
final header = conn.getHeaderFields()
return readHttpAttributes(header)
}
return null
}
protected XFileAttributes readHttpAttributes(Map<String,List<String>> header) {
final header0 = InsensitiveMap.<String,List<String>>of(header)
def lastMod = header0.get("Last-Modified")?.get(0)
long contentLen = header0.get("Content-Length")?.get(0)?.toLong() ?: -1L
def dateFormat = new SimpleDateFormat('E, dd MMM yyyy HH:mm:ss Z', Locale.ENGLISH) // <-- make sure date parse is not language dependent (for the week day)
def modTime = lastMod ? FileTime.from(dateFormat.parse(lastMod).time, TimeUnit.MILLISECONDS) : (FileTime)null
new XFileAttributes(modTime, contentLen)
}
}

View File

@@ -0,0 +1,323 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import java.nio.file.FileSystem
import java.nio.file.LinkOption
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.ProviderMismatchException
import java.nio.file.WatchEvent
import java.nio.file.WatchKey
import java.nio.file.WatchService
import groovy.transform.CompileStatic
/**
* Implements a {@link Path} for http/ftp protocols
*
* @author Emilio Palumbo <emilio.palumbo@crg.eu>
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@CompileStatic
class XPath implements Path {
static final public Set<String> ALL_SCHEMES = ['ftp','http','https'] as Set
private static final String[] EMPTY = []
private XFileSystem fs
private Path path
private String query
/*
* Only needed to prevent serialization issues - see https://github.com/nextflow-io/nextflow/issues/5208
*/
protected XPath(){}
XPath(XFileSystem fs, String path) {
this(fs, path, EMPTY)
}
XPath(XFileSystem fs, String path, String[] more) {
this.fs = fs
this.query = query(path)
this.path = Paths.get(stripQuery(path) ?:'/', more)
}
private XPath(XFileSystem fs, Path path, String query=null) {
this.fs = fs
this.path = path
this.query = query
}
private URI getBaseUri() {
fs?.getBaseUri()
}
private XPath createXPath(String path) {
fs && path.startsWith('/') ? new XPath(fs, path) : new XPath(null, path)
}
@Override
FileSystem getFileSystem() {
return fs
}
@Override
boolean isAbsolute() {
return path.isAbsolute()
}
@Override
Path getRoot() {
return createXPath("/")
}
@Override
Path getFileName() {
final result = path?.getFileName()?.toString()
return result ? new XPath(null, result) : null
}
@Override
Path getParent() {
String result = path.parent ? path.parent.toString() : null
if( result ) {
if( result != '/' ) result += '/'
return createXPath(result)
}
return null
}
@Override
int getNameCount() {
return path.toString() ? path.nameCount : 0
}
@Override
Path getName(int index) {
return new XPath(null, path.getName(index).toString())
}
@Override
Path subpath(int beginIndex, int endIndex) {
return new XPath(null, path.subpath(beginIndex, endIndex).toString())
}
@Override
Path normalize() {
return new XPath(fs, path.normalize(), query)
}
@Override
boolean startsWith(Path other) {
return startsWith(other.toString())
}
@Override
boolean startsWith(String other) {
return path.startsWith(other)
}
@Override
boolean endsWith(Path other) {
return endsWith(other.toString())
}
@Override
boolean endsWith(String other) {
return path.endsWith(other)
}
@Override
Path resolve(Path other) {
if( this.class != other.class )
throw new ProviderMismatchException()
def that = (XPath)other
if( that.fs && this.fs != that.fs )
return other
else if( that.path ) {
def newPath = this.path.resolve(that.path)
return new XPath(fs, newPath, that.query)
}
else {
return this
}
}
@Override
Path resolve(String other) {
resolve(get(other))
}
@Override
Path resolveSibling(Path other) {
if( this.class != other.class )
throw new ProviderMismatchException()
def that = (XPath)other
if( that.fs && this.fs != that.fs )
return other
if( that.path ) {
final Path newPath = this.path.resolveSibling(that.path)
return newPath.isAbsolute() ? new XPath(fs, newPath) : new XPath(null, newPath)
}
else {
return this
}
}
@Override
Path resolveSibling(String other) {
resolveSibling(get(other))
}
@Override
Path relativize(Path other) {
def otherPath = ((XPath)other).path
return createXPath(path.relativize(otherPath).toString())
}
@Override
URI toUri() {
final String concat = query!=null ? "$path?$query" : path.toString()
return baseUri ? new URI("$baseUri$concat") : new URI(concat)
}
@Override
Path toAbsolutePath() {
return this
}
@Override
Path toRealPath(LinkOption... options) throws IOException {
return this
}
@Override
File toFile() {
throw new UnsupportedOperationException()
}
@Override
String toString() {
return path.toString()
}
@Override
WatchKey register(WatchService watcher, WatchEvent.Kind<?>[] events, WatchEvent.Modifier... modifiers) throws IOException {
throw new UnsupportedOperationException("Register not supported by XFileSystem")
}
@Override
WatchKey register(WatchService watcher, WatchEvent.Kind<?>... events) throws IOException {
throw new UnsupportedOperationException("Register not supported by XFileSystem")
}
@Override
Iterator<Path> iterator() {
final len = getNameCount()
new Iterator<Path>() {
int index
Path current = len ? getName(index++) : null
@Override
boolean hasNext() {
return current != null
}
@Override
Path next() {
final result = current
current = index<len ? getName(index++) : null
return result
}
@Override
void remove() {
throw new UnsupportedOperationException("Remove operation not supported")
}
}
}
@Override
int compareTo(Path other) {
return this.toUri().toString() <=> other.toUri().toString()
}
@Override
boolean equals(Object other) {
if (other.class != XPath) {
return false
}
final that = (XPath)other
return this.fs == that.fs && this.path == that.path && this.query == that.query
}
/**
* @return The unique hash code for this path
*/
@Override
int hashCode() {
return Objects.hash(fs,path,query)
}
/**
* Path factory method.
*
* @param str
* A fully qualified URI path string, e.g. {@code http://www.host.name/data.file.txt}
* @return
* The corresponding {@link XPath} object
* @throws
* ProviderMismatchException When the URI scheme does not match any supported protocol, i.e. {@code ftp},
* {@code http}, {@code https}
*/
static XPath get(String str) {
if( str == null )
return null
def uri = new URI(null,null,str,null,null)
if( uri.scheme && !ALL_SCHEMES.contains(uri.scheme))
throw new ProviderMismatchException()
uri.authority ? (XPath)Paths.get(uri) : new XPath(null, str)
}
static String stripQuery(String uri) {
if(!uri)
return null
final p = uri.indexOf('?')
return p>0 ? uri.substring(0,p) : (p==0 ? null : uri)
}
static String query(String uri) {
if(!uri)
return null
final p = uri.indexOf('?')
return p!=-1 && p<uri.size()-1 ? uri.substring(p+1) : null
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import groovy.transform.CompileStatic
import nextflow.util.SerializerRegistrant
import org.pf4j.Extension
/**
* Register the {@link XPathSerializer} as Kryo serializer
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Extension
@CompileStatic
class XPathRegistrant implements SerializerRegistrant {
@Override
void register(Map<Class, Object> serializers) {
serializers.put(XPath, XPathSerializer)
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import com.esotericsoftware.kryo.Kryo
import com.esotericsoftware.kryo.Serializer
import com.esotericsoftware.kryo.io.Input
import com.esotericsoftware.kryo.io.Output
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import nextflow.file.FileHelper
/**
* Implements Kryo serializer for {@link XPath}
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@CompileStatic
class XPathSerializer extends Serializer<XPath> {
@Override
void write(Kryo kryo, Output output, XPath target) {
final uri = target.toUri().toString()
log.trace "XPath serialization > uri=$uri"
output.writeString(uri)
}
@Override
XPath read(Kryo kryo, Input input, Class<XPath> type) {
final uri = input.readString()
log.trace "Path de-serialization > uri=$uri"
(XPath) FileHelper.asPath(new URI(uri))
}
}

View File

@@ -0,0 +1,17 @@
#
# Copyright 2013-2024, Seqera Labs
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
nextflow.file.http.XPathRegistrant

View File

@@ -0,0 +1,19 @@
#
# Copyright 2013-2026, Seqera Labs
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
nextflow.file.http.FtpFileSystemProvider
nextflow.file.http.HttpFileSystemProvider
nextflow.file.http.HttpsFileSystemProvider

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import spock.lang.Specification
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class FixedInputStreamTest extends Specification {
def 'should read byte by byte' () {
given:
def bytes = "Hello world". bytes
def stream = new FixedInputStream(new ByteArrayInputStream(bytes), bytes.length)
when:
def ch
def result = new StringBuilder()
while( (ch=stream.read())!=-1 )
result.append(ch as char)
and:
stream.close()
then:
noExceptionThrown()
result.toString() == 'Hello world'
}
def 'should read byte buffer' () {
given:
def bytes = "Hello world". bytes
def stream = new FixedInputStream(new ByteArrayInputStream(bytes), bytes.length)
when:
def buffer = new byte[5]
def result = new StringBuilder()
def c
while( (c=stream.read(buffer))!=-1 ) {
for( int i=0; i<c; i++ )
result.append(buffer[i] as char)
}
and:
stream.close()
then:
noExceptionThrown()
result.toString() == 'Hello world'
}
def 'should read all bytes' () {
given:
def bytes = "Hello world". bytes
def stream = new FixedInputStream(new ByteArrayInputStream(bytes), bytes.length)
when:
def result = stream.readAllBytes()
and:
stream.close()
then:
noExceptionThrown()
and:
new String(result) == "Hello world"
}
}

View File

@@ -0,0 +1,292 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import java.nio.ByteBuffer
import java.nio.charset.Charset
import java.nio.file.Files
import java.nio.file.Paths
import com.github.tomakehurst.wiremock.junit.WireMockRule
import com.sun.net.httpserver.BasicAuthenticator
import com.sun.net.httpserver.Headers
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
import com.sun.net.httpserver.HttpServer
import groovy.transform.CompileStatic
import nextflow.SysEnv
import org.junit.Rule
import spock.lang.IgnoreIf
import spock.lang.Specification
import test.TestHelper
import static com.github.tomakehurst.wiremock.client.WireMock.*
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class HttpFilesTests extends Specification {
@Rule
WireMockRule wireMockRule = new WireMockRule(0)
def 'should read http file from WireMock' () {
given:
def localhost = "http://localhost:${wireMockRule.port()}"
wireMockRule.stubFor(get(urlEqualTo("/index.html"))
.willReturn(aResponse()
.withStatus(200)
.withBody("""a
b
c
d
""")
.withHeader("Content-Type", "text/html")
.withHeader("Content-Length", "10")
.withHeader("Last-Modified", "Fri, 04 Nov 2016 21:50:34 GMT")))
when:
def path = Paths.get(new URI("${localhost}/index.html"))
then:
Files.size(path) == 10
Files.getLastModifiedTime(path).toString() == "2016-11-04T21:50:34Z"
when:
def path2 = Paths.get(new URI("${localhost}missing.html"))
then:
!Files.exists(path2)
}
def 'should re-try read file' () {
given:
def RESP = 'Hello world'
and:
// launch web server
def attempt = 0
HttpServer server = HttpServer.create(new InetSocketAddress(9900), 0);
server.createContext("/", new BasicHandler(RESP, { ++attempt < 3 ? 404 : 200 } ));
server.start()
when:
def path = Paths.get(new URI('http://admin:Secret1@localhost:9900/foo/bar'))
then:
path.text == 'Hello world'
and:
attempt == 3
cleanup:
server?.stop(0)
}
@IgnoreIf({System.getenv('NXF_SMOKE')})
def 'read a http file ' () {
given:
def uri = new URI('http://www.nextflow.io/index.html')
when:
def path = Paths.get(uri)
then:
path instanceof XPath
when:
def lines = Files.readAllLines(path, Charset.forName('UTF-8'))
then:
lines.size()>0
lines[0].startsWith('<!DOCTYPE html><html lang="en">')
}
@IgnoreIf({System.getenv('NXF_SMOKE')})
def 'should check file properties' () {
given:
SysEnv.push([NXF_HTTPFS_MAX_ATTEMPTS: '1'])
when:
def path1 = Paths.get(new URI('http://www.nextflow.io/index.html'))
def path2 = Paths.get(new URI('http://www.google.com/unknown'))
def path3 = Paths.get(new URI('http://www.nextflow.io/index.html'))
then:
Files.exists(path1)
Files.size(path1) > 0
!Files.isDirectory(path1)
Files.isReadable(path1)
!Files.isExecutable(path1)
!Files.isWritable(path1)
!Files.isHidden(path1)
Files.isRegularFile(path1)
!Files.isSymbolicLink(path1)
Files.isSameFile(path1, path3)
!Files.isSameFile(path1, path2)
!Files.exists(path2)
cleanup:
SysEnv.pop()
}
@IgnoreIf({System.getenv('NXF_SMOKE')})
def 'should read FTP file' () {
when:
def lines = Paths.get(new URI('ftp://ftp.ncbi.nlm.nih.gov/robots.txt')).text.readLines()
then:
lines[0] == 'User-agent: *'
lines[1] == 'Disallow: /'
}
@IgnoreIf({System.getenv('NXF_SMOKE')})
def 'should read HTTPS file' () {
given:
def uri = new URI('https://www.nextflow.io/index.html')
when:
def path = Paths.get(uri)
then:
path instanceof XPath
when:
def lines = Files.readAllLines(path, Charset.forName('UTF-8'))
then:
lines.size()>0
lines[0].startsWith('<!DOCTYPE html><html lang="en">')
}
@IgnoreIf({System.getenv('NXF_SMOKE')})
def 'should copy a file' () {
given:
def uri = new URI('https://www.nextflow.io/index.html')
def source = Paths.get(uri)
def target = Files.createTempDirectory('nf').resolve('test.html')
when:
Files.copy(source, target)
then:
source.text == target.text
cleanup:
target?.parent?.deleteDir()
}
@IgnoreIf({System.getenv('NXF_SMOKE')})
def 'should read lines' () {
given:
def uri = new URI('http://www.nextflow.io/index.html')
when:
def path = Paths.get(uri)
then:
path instanceof XPath
when:
def str = new String(Files.readAllBytes(path), Charset.forName('UTF-8'))
then:
str.size()>0
str.startsWith('<!DOCTYPE html><html lang="en">')
}
def 'should read with a newByteChannel' () {
given:
def localhost = "http://localhost:${wireMockRule.port()}"
wireMockRule.stubFor(get(urlEqualTo("/index.txt"))
.willReturn(aResponse()
.withStatus(200)
.withBody("01234567890123456789012345678901234567890123456789")
.withHeader("Content-Type", "text/html")
.withHeader("Content-Length", "50")
.withHeader("Last-Modified", "Fri, 04 Nov 2016 21:50:34 GMT")))
when:
def path = Paths.get(new URI("${localhost}/index.txt"))
def sbc = Files.newByteChannel(path)
def buffer = new ByteArrayOutputStream()
ByteBuffer bf = ByteBuffer.allocate(15)
while((sbc.read(bf))>0) {
bf.flip();
buffer.write(bf.array(), 0, bf.limit())
bf.clear();
}
then:
buffer.toString() == path.text
buffer.toString() == '01234567890123456789012345678901234567890123456789'
}
def 'should use basic auth' () {
given:
def RESP = 'Hello world'
and:
// launch web server
int port = TestHelper.rndServerPort()
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
def hc1 = server.createContext("/", new BasicHandler(RESP, 200));
hc1.setAuthenticator(new BasicAuthenticator("get") {
@Override
boolean checkCredentials(String user, String pwd) {
return user.equals("admin") && pwd.equals("Secret1");
} });
server.start()
when:
def path = Paths.get(new URI("http://admin:Secret1@localhost:${port}/foo/bar"))
then:
path.text == 'Hello world'
cleanup:
server?.stop(0)
}
@CompileStatic
static class BasicHandler implements HttpHandler {
String body
Closure<Integer> respCode
Map<String,String> allHeaders
BasicHandler(String s, int code, Map<String,String> headers=null) {
this.body=s
this.respCode = { return code }
this.allHeaders = headers ?: Collections.<String,String>emptyMap()
}
BasicHandler(String s, Closure<Integer> code, Map<String,String> headers=null) {
this.body=s
this.respCode = code
this.allHeaders = headers ?: Collections.<String,String>emptyMap()
}
@Override
void handle(HttpExchange request) throws IOException {
Headers header = request.getResponseHeaders()
for( Map.Entry<String,String> entry : allHeaders.entrySet() ) {
header.set(entry.key, entry.value)
}
header.set("Content-Type", "text/plain")
request.sendResponseHeaders(respCode.call(), body.size())
OutputStream os = request.getResponseBody();
os.write(body.bytes);
os.close();
}
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import spock.lang.Specification
import spock.lang.Unroll
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class XAuthRegistryTest extends Specification {
@Unroll
def 'should register authenticator'() {
given:
def authenticator = new XAuthRegistry()
and:
def provider = Mock(XAuthProvider)
authenticator.register(provider)
and:
def conn1 = Mock(URLConnection)
when:
def result = authenticator.authorize(conn1)
then:
1 * provider.authorize(conn1) >> ACCEPT
and:
result == EXPECTED
cleanup:
authenticator.unregister(provider)
where:
ACCEPT | EXPECTED
false | false
true | true
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import spock.lang.Specification
import nextflow.SysEnv
/**
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
class XFileSystemConfigTest extends Specification {
def 'should create with default config settings' () {
when:
def config = new XFileSystemConfig()
then:
config.retryCodes() == XFileSystemConfig.DEFAULT_RETRY_CODES.tokenize(',').collect( it -> it as int )
config.backOffDelay() == XFileSystemConfig.DEFAULT_BACK_OFF_DELAY
config.backOffBase() == XFileSystemConfig.DEFAULT_BACK_OFF_BASE
config.maxAttempts() == XFileSystemConfig.DEFAULT_MAX_ATTEMPTS
}
def 'should create with custom config settings' () {
given:
SysEnv.push([NXF_HTTPFS_MAX_ATTEMPTS: '10',
NXF_HTTPFS_BACKOFF_BASE: '300',
NXF_HTTPFS_DELAY : '400',
NXF_HTTPFS_RETRY_CODES : '1,2,3'])
when:
def config = new XFileSystemConfig()
then:
config.retryCodes() == [1,2,3]
config.backOffDelay() == 400
config.backOffBase() == 300
config.maxAttempts() == 10
cleanup:
SysEnv.pop()
}
}

View File

@@ -0,0 +1,231 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import java.nio.file.Files
import java.nio.file.Path
import com.github.tomakehurst.wiremock.junit.WireMockRule
import org.junit.Rule
import spock.lang.IgnoreIf
import spock.lang.Specification
import spock.lang.Unroll
import static com.github.tomakehurst.wiremock.client.WireMock.*
/**
* Created by emilio on 08/11/16.
*/
class XFileSystemProviderTest extends Specification {
@IgnoreIf({System.getenv('NXF_SMOKE')})
def "should return input stream"() {
given:
def fsp = new HttpFileSystemProvider()
def path = fsp.getPath(new URI('http://www.google.com/index.html'))
when:
def stream = fsp.newInputStream(path)
then:
stream.text.startsWith("<!doctype html>")
}
def "should return input stream from path"() {
given:
def DATA = 'Hello world'
def fsp = Spy(new HttpFileSystemProvider())
def path = fsp.getPath(new URI('http://host.com/index.html?query=123'))
def connection = Mock(URLConnection)
when:
def stream = fsp.newInputStream(path)
then:
fsp.toConnection(path) >> { Path it ->
assert it instanceof XPath
assert it.toUri() == new URI('http://host.com/index.html?query=123')
return connection
}
and:
connection.getInputStream() >> new ByteArrayInputStream(DATA.bytes)
connection.getContentLengthLong() >> DATA.size()
and:
stream.text == 'Hello world'
}
def "should read file attributes from map"() {
given:
def fs = new HttpFileSystemProvider()
def attrMap = ['last-modified': ['Fri, 04 Nov 2016 21:50:34 GMT'], 'content-length': ['21729']]
when:
def attrs = fs.readHttpAttributes(attrMap)
then:
attrs.lastModifiedTime().toString() == '2016-11-04T21:50:34Z'
attrs.size() == 21729
when:
attrs = fs.readHttpAttributes([:])
then:
attrs.lastModifiedTime() == null
attrs.size() == -1
}
def "should read file attributes with german lang"() {
given:
def defLocale = Locale.getDefault(Locale.Category.FORMAT)
// set german as current language
def GERMAN = new Locale.Builder().setLanguage("de").setRegion("DE").build()
Locale.setDefault(Locale.Category.FORMAT, GERMAN)
def fs = new HttpFileSystemProvider()
def attrMap = ['last-modified': ['Fri, 04 Nov 2016 21:50:34 GMT'], 'content-length': ['21729']]
when:
def attrs = fs.readHttpAttributes(attrMap)
then:
attrs.lastModifiedTime().toString() == '2016-11-04T21:50:34Z'
attrs.size() == 21729
cleanup:
Locale.setDefault(Locale.Category.FORMAT, defLocale)
}
@IgnoreIf({System.getenv('NXF_SMOKE')})
def "should read file attributes from HttpPath"() {
given:
def fsp = new HttpFileSystemProvider()
def path = (XPath) fsp.getPath(new URI('http://www.nextflow.io/index.html'))
when:
def attrs = fsp.readHttpAttributes(path)
then:
attrs.lastModifiedTime()
attrs.size() > 0
}
@IgnoreIf({System.getenv('NXF_SMOKE')})
def "should read file attributes from FtpPath"() {
given:
def fsp = new FtpFileSystemProvider()
def path = (XPath) fsp.getPath(new URI('ftp://ftp.ebi.ac.uk/robots.txt'))
when:
def attrs = fsp.readHttpAttributes(path)
then:
attrs.lastModifiedTime() == null
attrs.size() == -1
}
@Unroll
def 'should get uri path'() {
given:
def provider = new HttpFileSystemProvider()
when:
def path = provider.getPath(new URI(PATH))
then:
path.toUri().toString() == EXPECTED
where:
PATH | EXPECTED
'http://foo.com/this/that' | 'http://foo.com/this/that'
'http://FOO.com/this/that' | 'http://foo.com/this/that'
'http://MrXYZ@foo.com/this/that' | 'http://MrXYZ@foo.com/this/that'
'http://MrXYZ@FOO.com/this/that' | 'http://MrXYZ@foo.com/this/that'
'http://@FOO.com/this/that' | 'http://@foo.com/this/that'
'http://foo.com/this/that?foo=1' | 'http://foo.com/this/that?foo=1'
}
@Unroll
def 'should encode user info'() {
given:
def provider = new HttpFileSystemProvider()
expect:
provider.auth(USER_INFO) == EXPECTED
where:
USER_INFO | EXPECTED
"foo:bar" | "Basic ${'foo:bar'.bytes.encodeBase64()}"
"x-oauth-bearer:12345" | "Bearer 12345"
}
@Rule
WireMockRule wireMockRule = new WireMockRule(0)
@Unroll
def 'should follow a redirect when read a http file '() {
given:
def localhost = "http://localhost:${wireMockRule.port()}"
wireMockRule.stubFor(get(urlEqualTo("/index.html"))
.willReturn(aResponse()
.withStatus(HTTP_CODE)
.withHeader("Location", "${localhost}${REDIRECT_TO}")))
wireMockRule.stubFor(get(urlEqualTo("/index2.html"))
.willReturn(aResponse()
.withStatus(HTTP_CODE)
.withHeader("Location", "${localhost}/target.html")))
wireMockRule.stubFor(get(urlEqualTo("/target.html"))
.willReturn(aResponse()
.withStatus(200)
.withBody("""a
b
c
d
""")
.withHeader("Content-Type", "text/html")
.withHeader("Content-Length", "10")
.withHeader("Last-Modified", "Fri, 04 Nov 2016 21:50:34 GMT")))
and:
def provider = new HttpFileSystemProvider()
when:
def path = provider.getPath(new URI("${localhost}/index.html"))
then:
path
Files.size(path) == EXPECTED
where:
HTTP_CODE | REDIRECT_TO | EXPECTED
301 | "/target.html" | 10
301 | "/index2.html" | 10
302 | "/target.html" | 10
302 | "/index2.html" | 10
307 | "/target.html" | 10
307 | "/index2.html" | 10
308 | "/target.html" | 10
308 | "/index2.html" | 10
//infinite redirect to himself
308 | "/index.html" | -1
}
def 'should normalize location' () {
given:
def provider = Spy(XFileSystemProvider)
expect:
provider.absLocation(LOCATION, new URL(TARGET)) == EXPECTED
where:
LOCATION | TARGET | EXPECTED
'https://this/that' | 'http://foo.com:123' | 'https://this/that'
'/' | 'http://foo.com:123' | 'http://foo.com:123/'
'/this/that' | 'http://foo.com:123' | 'http://foo.com:123/this/that'
'/this/that' | 'http://foo.com:123/abc' | 'http://foo.com:123/this/that'
'this/that' | 'http://foo.com:123/abc' | 'http://foo.com:123/this/that'
}
}

View File

@@ -0,0 +1,275 @@
/*
* Copyright 2013-2026, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package nextflow.file.http
import spock.lang.Specification
import spock.lang.Unroll
/**
* @author Emilio Palumbo
* @author Paolo DiTommaso
*
*/
class XPathTest extends Specification {
def 'should validate equals and hashCode' () {
when:
def p1 = XPath.get('http://www.nextflow.io/a/b/c.txt')
def p2 = XPath.get('http://www.nextflow.io/a/b/c.txt')
def p3 = XPath.get('http://www.nextflow.io/z.txt')
def p4 = XPath.get('http://www.google.com/a/b/c.txt')
then:
p1 == p2
p1 != p3
p1 != p4
p1.equals(p2)
!p1.equals(p3)
!p1.equals(p4)
p1.hashCode() == p2.hashCode()
p1.hashCode() != p3.hashCode()
p1.hashCode() != p4.hashCode()
}
def 'should convert to a string' () {
expect:
XPath.get('http://www.nextflow.io/abc/d.txt').toString()== '/abc/d.txt'
and:
XPath.get('http://www.nextflow.io/abc/d.txt').toUri() == new URI('http://www.nextflow.io/abc/d.txt')
XPath.get('http://www.nextflow.io/abc/d.txt').toUri().toString() == 'http://www.nextflow.io/abc/d.txt'
and:
XPath.get('http://www.nextflow.io/abc/d.txt?q=1').toUri() == new URI('http://www.nextflow.io/abc/d.txt?q=1')
XPath.get('http://www.nextflow.io/abc/d.txt?q=1').toUri().toString() == 'http://www.nextflow.io/abc/d.txt?q=1'
and:
XPath.get('http://www.nextflow.io/abc/d.txt?').toUri().toString() == 'http://www.nextflow.io/abc/d.txt'
}
def 'should validate uri' () {
when:
def uri = XPath.get(LOCATION).toUri()
then:
uri.authority == AUTH
uri.path == PATH
uri.query == QUERY
uri.scheme == SCHEME
where:
LOCATION | SCHEME | AUTH | PATH | QUERY
'http://www.nextflow.io/abc/d.txt' | 'http' | 'www.nextflow.io' | '/abc/d.txt' | null
'http://www.nextflow.io/abc/d.txt?q=1' | 'http' | 'www.nextflow.io' | '/abc/d.txt' | 'q=1'
}
def "should return url root"() {
expect:
XPath.get(origin).getRoot() == XPath.get(root)
XPath.get(origin).getRoot().toString() == '/'
XPath.get(origin).getRoot().toUri() == new URI(uri)
where:
origin | root | path | uri
'http://www.google.com/abc.txt' | 'http://www.google.com/' | '/' | 'http://www.google.com/'
'http://www.google.com/' | 'http://www.google.com/' | '/' | 'http://www.google.com/'
'http://www.google.com' | 'http://www.google.com/' | '/' | 'http://www.google.com/'
}
@Unroll
def 'should return file name from url' () {
expect:
XPath.get(ORIGIN)?.getFileName() == XPath.get(FILE_NAME)
XPath.get(ORIGIN)?.getFileName()?.toString() == FILE_NAME
where:
ORIGIN | FILE_NAME
'http://nextflow.io/a/b/c.txt' | 'c.txt'
'http://nextflow.io/alpha' | 'alpha'
'http://nextflow.io/' | null
'http://nextflow.io' | null
'http://nextflow.io/a/b/file.txt?q=1'| 'file.txt'
}
def 'should return if it is an absolute path'() {
expect:
XPath.get(origin).isAbsolute() == expected
where:
origin | expected
'http://nextflow.io/a/b/c.txt' | true
'http://nextflow.io/a/b/' | true
'name.txt' | false
}
def 'should return parent path' () {
expect:
XPath.get(origin).getParent() == XPath.get(parent)
where:
origin | parent
'http://nextflow.io/a/b/c.txt' | 'http://nextflow.io/a/b/'
'http://nextflow.io/a/' | 'http://nextflow.io/'
'http://nextflow.io/a' | 'http://nextflow.io/'
'http://nextflow.io/' | null
}
@Unroll
def 'should return name count #origin' () {
expect:
XPath.get(origin).getNameCount() == count
where:
origin | count
'http://nextflow.io/a/b/c.txt' | 3
'http://nextflow.io/a/b/' | 2
'http://nextflow.io/a/b' | 2
'http://nextflow.io/a/' | 1
'http://nextflow.io/' | 0
'http://nextflow.io' | 0
'hello/world' | 2
'hello' | 1
}
def 'should return name part by index' () {
expect:
XPath.get('http://nextflow.io/a/b/c.txt').getName(0) == XPath.get('a')
XPath.get('http://nextflow.io/a/b/c.txt').getName(1) == XPath.get('b')
XPath.get('http://nextflow.io/a/b/c.txt').getName(2) == XPath.get('c.txt')
when:
XPath.get('http://nextflow.io/a/b/c.txt').getName(3)
then:
thrown(IllegalArgumentException)
}
def 'should return a subpath' () {
expect:
XPath.get('http://nextflow.io/a/b/c/d.txt').subpath(0,3) == XPath.get('a/b/c' )
XPath.get('http://nextflow.io/a/b/c/d.txt').subpath(1,3) == XPath.get('b/c' )
XPath.get('http://nextflow.io/a/b/c/d.txt').subpath(3,4) == XPath.get('d.txt' )
}
@Unroll
def 'should resolve a path: #base' () {
expect:
XPath.get(base).resolve(ext) == XPath.get(expected)
XPath.get(base).resolve(XPath.get(ext)) == XPath.get(expected)
where:
base | ext | expected
'http://nextflow.io/abc' | 'd.txt' | 'http://nextflow.io/abc/d.txt'
'http://nextflow.io/abc/' | 'd.txt' | 'http://nextflow.io/abc/d.txt'
'http://nextflow.io' | 'file.txt' | 'http://nextflow.io/file.txt'
'http://nextflow.io/' | 'file.txt' | 'http://nextflow.io/file.txt'
'alpha' | 'beta.txt' | 'alpha/beta.txt'
'/alpha' | 'beta.txt' | '/alpha/beta.txt'
'http://nextflow.io/abc/' | '/z.txt' | 'http://nextflow.io/z.txt'
'http://nextflow.io/abc/' | 'http://x.com/z.txt' | 'http://x.com/z.txt'
and:
'http://nextflow.io/abc/' | 'http://x.com/z.txt?q=1' | 'http://x.com/z.txt?q=1'
'http://nextflow.io/abc/' | 'z.txt?q=1' | 'http://nextflow.io/abc/z.txt?q=1'
}
@Unroll
def 'should resolve sibling: #base' () {
expect:
XPath.get(base).resolveSibling(ext) == XPath.get(expected)
XPath.get(base).resolveSibling(XPath.get(ext)) == XPath.get(expected)
where:
base | ext | expected
'http://nextflow.io/ab/c' | 'd.txt' | 'http://nextflow.io/ab/d.txt'
'http://nextflow.io/ab/c/' | 'd.txt' | 'http://nextflow.io/ab/d.txt'
'http://nextflow.io/ab' | 'd.txt' | 'http://nextflow.io/d.txt'
'http://nextflow.io/ab/' | 'd.txt' | 'http://nextflow.io/d.txt'
'http://nextflow.io/' | 'd.txt' | 'd.txt'
'http://nextflow.io/a/b/c/' | '/p/q.txt' | 'http://nextflow.io/p/q.txt'
'http://nextflow.io/abc/' | 'http://x.com/z.txt' | 'http://x.com/z.txt'
}
def 'should normalise a path: #path' () {
expect:
XPath.get(path).normalize() == XPath.get(expected)
where:
path | expected
'http://nextflow.io/ab/c.txt' | 'http://nextflow.io/ab/c.txt'
'http://nextflow.io/ab/c/../d.txt' | 'http://nextflow.io/ab/d.txt'
'ab/c/../d.txt' | 'ab/d.txt'
'http://nextflow.io/ab/c.txt?q=1' | 'http://nextflow.io/ab/c.txt?q=1'
}
def 'should iterate over a path' () {
when:
def itr = XPath.get('http://nextflow.io/a/b/c.txt').iterator()
then:
itr.hasNext()
itr.next() == XPath.get('a')
itr.hasNext()
itr.next() == XPath.get('b')
itr.hasNext()
itr.next() == XPath.get('c.txt')
!itr.hasNext()
itr.next() == null
}
@Unroll
def 'should fetch query' () {
expect:
XPath.query(PATH) == EXPECTED
where:
PATH | EXPECTED
null | null
'foo.txt' | null
'foo?x=1' | 'x=1'
'foo?x=1&y=2' | 'x=1&y=2'
'foo?' | null
'?x' | 'x'
}
@Unroll
def 'should strip query' () {
expect:
XPath.stripQuery(PATH) == EXPECTED
where:
PATH | EXPECTED
null | null
'foo.txt' | 'foo.txt'
'foo?x=1' | 'foo'
'foo?x=1&y=2' | 'foo'
'foo?' | 'foo'
'?x' | null
}
}