Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -792,11 +792,12 @@ private <T> void verifySupportedOptions(Set<? extends T> allowedOptions,
"the following options are not supported: %s", unsupported);
}
/**
* check that the paths exists or not
* check that the path exists or not
* @param path S3Path
* @return true if exists
* @throws IOException if an I/O error occurs (including AccessDeniedException for permission issues)
*/
private boolean exists(S3Path path) {
private boolean exists(S3Path path) throws IOException {
try {
s3ObjectSummaryLookup.lookup(path);
return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2020-2022, Seqera Labs
* Copyright 2020-2025, Seqera Labs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,12 +17,16 @@

package nextflow.cloud.aws.nio.util;

import java.io.IOException;
import java.nio.file.AccessDeniedException;
import java.nio.file.NoSuchFileException;
import java.util.List;

import nextflow.cloud.aws.nio.S3Client;
import software.amazon.awssdk.services.s3.model.*;
import nextflow.cloud.aws.nio.S3Path;
import software.amazon.awssdk.awscore.exception.AwsServiceException;
import software.amazon.awssdk.core.exception.SdkException;
import software.amazon.awssdk.services.s3.model.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -35,8 +39,10 @@ public class S3ObjectSummaryLookup {
* @param s3Path {@link S3Path}
* @return {@link software.amazon.awssdk.services.s3.model.S3Object}
* @throws java.nio.file.NoSuchFileException if not found the path and any child
* @throws java.nio.file.AccessDeniedException if access is denied due to missing credentials or permissions
* @throws java.io.IOException for other S3 access errors
*/
public S3Object lookup(S3Path s3Path) throws NoSuchFileException {
public S3Object lookup(S3Path s3Path) throws IOException {

/*
* check is object summary has been cached
Expand All @@ -47,60 +53,99 @@ public S3Object lookup(S3Path s3Path) throws NoSuchFileException {
}

final S3Client client = s3Path.getFileSystem().getClient();
final String path = s3Path.toS3ObjectId().toString();

/*
* when `key` is an empty string retrieve the object meta-data of the bucket
*/
if( "".equals(s3Path.getKey()) ) {
HeadObjectResponse meta = client.getObjectMetadata(s3Path.getBucket(), "");
if( meta == null )
throw new NoSuchFileException("s3://" + s3Path.getBucket());

summary = S3Object.builder()
.eTag(meta.eTag())
.key(s3Path.getKey())
.lastModified(meta.lastModified())
.size(meta.contentLength())
.build();

// TODO summary.setOwner(?);
// TODO summary.setStorageClass(?);
return summary;
}

/*
* Lookup for the object summary for the specified object key
* by using a `listObjects` request
*/
String marker = null;
while( true ) {
ListObjectsRequest.Builder request = ListObjectsRequest.builder();
request.bucket(s3Path.getBucket());
request.prefix(s3Path.getKey());
request.maxKeys(250);
if( marker != null )
request.marker(marker);

ListObjectsResponse listing = client.listObjects(request.build());
List<S3Object> results = listing.contents();

if (results.isEmpty()){
break;
try {
/*
* when `key` is an empty string retrieve the object meta-data of the bucket
*/
if( "".equals(s3Path.getKey()) ) {
HeadObjectResponse meta = client.getObjectMetadata(s3Path.getBucket(), "");
if( meta == null )
throw new NoSuchFileException("s3://" + s3Path.getBucket());

summary = S3Object.builder()
.eTag(meta.eTag())
.key(s3Path.getKey())
.lastModified(meta.lastModified())
.size(meta.contentLength())
.build();

// TODO summary.setOwner(?);
// TODO summary.setStorageClass(?);
return summary;
}

for( S3Object item : results ) {
if( matchName(s3Path.getKey(), item)) {
return item;
/*
* Lookup for the object summary for the specified object key
* by using a `listObjects` request
*/
String marker = null;
while( true ) {
ListObjectsRequest.Builder request = ListObjectsRequest.builder();
request.bucket(s3Path.getBucket());
request.prefix(s3Path.getKey());
request.maxKeys(250);
if( marker != null )
request.marker(marker);

ListObjectsResponse listing = client.listObjects(request.build());
List<S3Object> results = listing.contents();

if (results.isEmpty()){
break;
}

for( S3Object item : results ) {
if( matchName(s3Path.getKey(), item)) {
return item;
}
}

if( listing.isTruncated() )
marker = listing.nextMarker();
else
break;
}

if( listing.isTruncated() )
marker = listing.nextMarker();
else
break;
throw new NoSuchFileException("s3://" + s3Path.getBucket() + "/" + s3Path.getKey());
}
catch (SdkException e) {
throw translateException(e, path);
}
}

/**
* Translate AWS SDK exceptions to standard Java NIO exceptions with clear error messages.
*
* @param e the AWS SDK exception
* @param path the S3 path being accessed (for error messages)
* @return an appropriate IOException subclass
*/
IOException translateException(SdkException e, String path) {
// Check for service exceptions with HTTP status codes
if (e instanceof AwsServiceException) {
int statusCode = ((AwsServiceException) e).statusCode();
if (statusCode == 403 || statusCode == 401) {
return new AccessDeniedException(path, null,
"Access denied to S3 path - check AWS credentials and permissions");
}
if (statusCode == 404) {
return new NoSuchFileException(path);
}
}

// Check for client-side credential errors based on message patterns
String message = e.getMessage();
if (message != null && (
message.contains("Unable to load credentials") ||
message.contains("Unable to marshall request"))) {
return new AccessDeniedException(path, null,
"Cannot access S3 path - AWS credentials may not be configured");
}

throw new NoSuchFileException("s3://" + s3Path.getBucket() + "/" + s3Path.getKey());
// Default: wrap as IOException with original cause
return new IOException("S3 error accessing path: " + path, e);
}

private boolean matchName(String fileName, S3Object summary) {
Expand All @@ -118,16 +163,18 @@ private boolean matchName(String fileName, S3Object summary) {
return foundKey.charAt(fileName.length()) == '/';
}

public HeadObjectResponse getS3ObjectMetadata(S3Path s3Path) {
public HeadObjectResponse getS3ObjectMetadata(S3Path s3Path) throws IOException {
S3Client client = s3Path.getFileSystem().getClient();
final String path = s3Path.toS3ObjectId().toString();
try {
return client.getObjectMetadata(s3Path.getBucket(), s3Path.getKey());
}
catch (S3Exception e){
if (e.statusCode() != 404){
throw e;
catch (SdkException e) {
// Check if it's a 404 - return null for not found
if (e instanceof AwsServiceException && ((AwsServiceException) e).statusCode() == 404) {
return null;
}
return null;
throw translateException(e, path);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright 2020-2025, 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.cloud.aws.nio.util

import java.nio.file.AccessDeniedException
import java.nio.file.NoSuchFileException

import software.amazon.awssdk.awscore.exception.AwsServiceException
import software.amazon.awssdk.core.exception.SdkClientException
import software.amazon.awssdk.services.s3.model.S3Exception
import spock.lang.Specification

/**
* Tests for S3ObjectSummaryLookup exception translation
*
* @author Jonathan Manning
*/
class S3ObjectSummaryLookupTest extends Specification {

S3ObjectSummaryLookup lookup = new S3ObjectSummaryLookup()

def 'should translate 403 status to AccessDeniedException'() {
given:
def s3Exception = S3Exception.builder()
.statusCode(403)
.message("Access Denied")
.build()

when:
def result = lookup.translateException(s3Exception, "s3://bucket/key")

then:
result instanceof AccessDeniedException
result.message.contains("Access denied")
result.message.contains("credentials")
}

def 'should translate 401 status to AccessDeniedException'() {
given:
def s3Exception = S3Exception.builder()
.statusCode(401)
.message("Unauthorized")
.build()

when:
def result = lookup.translateException(s3Exception, "s3://bucket/key")

then:
result instanceof AccessDeniedException
result.message.contains("Access denied")
}

def 'should translate 404 status to NoSuchFileException'() {
given:
def s3Exception = S3Exception.builder()
.statusCode(404)
.message("Not Found")
.build()

when:
def result = lookup.translateException(s3Exception, "s3://bucket/key")

then:
result instanceof NoSuchFileException
}

def 'should translate credential loading errors to AccessDeniedException'() {
given:
def clientException = SdkClientException.builder()
.message("Unable to load credentials from any of the providers in the chain")
.build()

when:
def result = lookup.translateException(clientException, "s3://bucket/key")

then:
result instanceof AccessDeniedException
result.message.contains("credentials")
}

def 'should translate marshall errors to AccessDeniedException'() {
given:
def clientException = SdkClientException.builder()
.message("Unable to marshall request to JSON: Key cannot be empty")
.build()

when:
def result = lookup.translateException(clientException, "s3://bucket/key")

then:
result instanceof AccessDeniedException
result.message.contains("credentials")
}

def 'should wrap other SDK exceptions as IOException'() {
given:
def s3Exception = S3Exception.builder()
.statusCode(500)
.message("Internal Server Error")
.build()

when:
def result = lookup.translateException(s3Exception, "s3://bucket/key")

then:
result instanceof IOException
!(result instanceof AccessDeniedException)
!(result instanceof NoSuchFileException)
result.message.contains("s3://bucket/key")
result.cause == s3Exception
}
}
Loading