What is the algorithm to compute the Amazon-S3 Etag for a file larger than 5GB?

馋奶兔 提交于 2019-11-26 06:53:11
Emerson Farrugia

Just verified one. Hats off to Amazon for making it simple enough to be guessable.

Say you uploaded a 14MB file and your part size is 5MB. Calculate 3 MD5 checksums corresponding to each part, i.e. the checksum of the first 5MB, the second 5MB, and the last 4MB. Then take the checksum of their concatenation. Since MD5 checksums are hex representations of binary data, just make sure you take the MD5 of the decoded binary concatenation, not of the ASCII or UTF-8 encoded concatenation. When that's done, add a hyphen and the number of parts to get the ETag.

Here are the commands to do it on Mac OS X from the console:

$ dd bs=1m count=5 skip=0 if=someFile | md5 >>checksums.txt
5+0 records in
5+0 records out
5242880 bytes transferred in 0.019611 secs (267345449 bytes/sec)
$ dd bs=1m count=5 skip=5 if=someFile | md5 >>checksums.txt
5+0 records in
5+0 records out
5242880 bytes transferred in 0.019182 secs (273323380 bytes/sec)
$ dd bs=1m count=5 skip=10 if=someFile | md5 >>checksums.txt
2+1 records in
2+1 records out
2599812 bytes transferred in 0.011112 secs (233964895 bytes/sec)

At this point all the checksums are in checksums.txt. To concatenate them and decode the hex and get the MD5 checksum of the lot, just use

$ xxd -r -p checksums.txt | md5

And now append "-3" to get the ETag, since there were 3 parts.

It's worth noting that md5 on Mac OS X just writes out the checksum, but md5sum on Linux also outputs the filename. You'll need to strip that, but I'm sure there's some option to only output the checksums. You don't need to worry about whitespace cause xxd will ignore it.

Note: If you uploaded with aws-cli via aws s3 cp then you most likely have a 8MB chunksize. According to the docs, that is the default.

Update: I was told about an implementation of this at https://github.com/Teachnova/s3md5, which doesn't work on OS X. Here's a Gist I wrote with a working script for OS X.

okwap

Same algorithm, java version: (BaseEncoding, Hasher, Hashing, etc comes from the guava library

/**
 * Generate checksum for object came from multipart upload</p>
 * </p>
 * AWS S3 spec: Entity tag that identifies the newly created object's data. Objects with different object data will have different entity tags. The entity tag is an opaque string. The entity tag may or may not be an MD5 digest of the object data. If the entity tag is not an MD5 digest of the object data, it will contain one or more nonhexadecimal characters and/or will consist of less than 32 or more than 32 hexadecimal digits.</p> 
 * Algorithm follows AWS S3 implementation: https://github.com/Teachnova/s3md5</p>
 */
private static String calculateChecksumForMultipartUpload(List<String> md5s) {      
    StringBuilder stringBuilder = new StringBuilder();
    for (String md5:md5s) {
        stringBuilder.append(md5);
    }

    String hex = stringBuilder.toString();
    byte raw[] = BaseEncoding.base16().decode(hex.toUpperCase());
    Hasher hasher = Hashing.md5().newHasher();
    hasher.putBytes(raw);
    String digest = hasher.hash().toString();

    return digest + "-" + md5s.size();
}

bash implementation

python implementation

The algorithm literally is (copied from the readme in the python implementation) :

  1. md5 the chunks
  2. glob the md5 strings together
  3. convert the glob to binary
  4. md5 the binary of the globbed chunk md5s
  5. append "-Number_of_chunks" to the end of the md5 string of the binary
hyperknot

Based on answers here, I wrote a Python implementation which correctly calculates both multi-part and single-part file ETags.

def calculate_s3_etag(file_path, chunk_size=8 * 1024 * 1024):
    md5s = []

    with open(file_path, 'rb') as fp:
        while True:
            data = fp.read(chunk_size)
            if not data:
                break
            md5s.append(hashlib.md5(data))

    if len(md5s) == 1:
        return '"{}"'.format(md5s[0].hexdigest())

    digests = b''.join(m.digest() for m in md5s)
    digests_md5 = hashlib.md5(digests)
    return '"{}-{}"'.format(digests_md5.hexdigest(), len(md5s))

The default chunk_size is 8 MB used by the official aws cli tool, and it does multipart upload for 2+ chunks. It should work under both Python 2 and 3.

Not sure if it can help:

We're currently doing an ugly (but so far useful) hack to fix those wrong ETags in multipart uploaded files, which consists on applying a change to the file in the bucket; that triggers a md5 recalculation from Amazon that changes the ETag to matches with the actual md5 signature.

In our case:

File: bucket/Foo.mpg.gpg

  1. ETag obtained: "3f92dffef0a11d175e60fb8b958b4e6e-2"
  2. Do something with the file (rename it, add a meta-data like a fake header, among others)
  3. Etag obtained: "c1d903ca1bb6dc68778ef21e74cc15b0"

We don't know the algorithm, but since we can "fix" the ETag we don't need to worry about it either.

In an above answer, someone asked if there was a way to get the md5 for files larger than 5G.

An answer that I could give for getting the MD5 value (for files larger than 5G) would be to either add it manually to the metadata, or use a program to do your uploads which will add the information.

For example, I used s3cmd to upload a file, and it added the following metadata.

$ aws s3api head-object --bucket xxxxxxx --key noarch/epel-release-6-8.noarch.rpm 
{
  "AcceptRanges": "bytes", 
  "ContentType": "binary/octet-stream", 
  "LastModified": "Sat, 19 Sep 2015 03:27:25 GMT", 
  "ContentLength": 14540, 
  "ETag": "\"2cd0ae668a585a14e07c2ea4f264d79b\"", 
  "Metadata": {
    "s3cmd-attrs": "uid:502/gname:staff/uname:xxxxxx/gid:20/mode:33188/mtime:1352129496/atime:1441758431/md5:2cd0ae668a585a14e07c2ea4f264d79b/ctime:1441385182"
  }
}

It isn't a direct solution using the ETag, but it is a way to populate the metadata you want (MD5) in a way you can access it. It will still fail if someone uploads the file without metadata.

According to the AWS documentation the ETag isn't an MD5 hash for a multi-part upload nor for an encrypted object: http://docs.aws.amazon.com/AmazonS3/latest/API/RESTCommonResponseHeaders.html

Objects created by the PUT Object, POST Object, or Copy operation, or through the AWS Management Console, and are encrypted by SSE-S3 or plaintext, have ETags that are an MD5 digest of their object data.

Objects created by the PUT Object, POST Object, or Copy operation, or through the AWS Management Console, and are encrypted by SSE-C or SSE-KMS, have ETags that are not an MD5 digest of their object data.

If an object is created by either the Multipart Upload or Part Copy operation, the ETag is not an MD5 digest, regardless of the method of encryption.

Here is the algorithm in ruby...

require 'digest'

# PART_SIZE should match the chosen part size of the multipart upload
# Set here as 10MB
PART_SIZE = 1024*1024*10 

class File
  def each_part(part_size = PART_SIZE)
    yield read(part_size) until eof?
  end
end

file = File.new('<path_to_file>')

hashes = []

file.each_part do |part|
  hashes << Digest::MD5.hexdigest(part)
end

multipart_hash = Digest::MD5.hexdigest([hashes.join].pack('H*'))
multipart_etag = "#{multipart_hash}-#{hashes.count}"

Thanks to Shortest Hex2Bin in Ruby and Multipart Uploads to S3 ...

And here is a PHP version of calculating the ETag:

function calculate_aws_etag($filename, $chunksize) {
    /*
    DESCRIPTION:
    - calculate Amazon AWS ETag used on the S3 service
    INPUT:
    - $filename : path to file to check
    - $chunksize : chunk size in Megabytes
    OUTPUT:
    - ETag (string)
    */
    $chunkbytes = $chunksize*1024*1024;
    if (filesize($filename) < $chunkbytes) {
        return md5_file($filename);
    } else {
        $md5s = array();
        $handle = fopen($filename, 'rb');
        if ($handle === false) {
            return false;
        }
        while (!feof($handle)) {
            $buffer = fread($handle, $chunkbytes);
            $md5s[] = md5($buffer);
            unset($buffer);
        }
        fclose($handle);

        $concat = '';
        foreach ($md5s as $indx => $md5) {
            $concat .= hex2bin($md5);
        }
        return md5($concat) .'-'. count($md5s);
    }
}

$etag = calculate_aws_etag('path/to/myfile.ext', 8);

And here is an enhanced version that can verify against an expected ETag - and even guess the chunksize if you don't know it!

function calculate_etag($filename, $chunksize, $expected = false) {
    /*
    DESCRIPTION:
    - calculate Amazon AWS ETag used on the S3 service
    INPUT:
    - $filename : path to file to check
    - $chunksize : chunk size in Megabytes
    - $expected : verify calculated etag against this specified etag and return true or false instead
        - if you make chunksize negative (eg. -8 instead of 8) the function will guess the chunksize by checking all possible sizes given the number of parts mentioned in $expected
    OUTPUT:
    - ETag (string)
    - or boolean true|false if $expected is set
    */
    if ($chunksize < 0) {
        $do_guess = true;
        $chunksize = 0 - $chunksize;
    } else {
        $do_guess = false;
    }

    $chunkbytes = $chunksize*1024*1024;
    $filesize = filesize($filename);
    if ($filesize < $chunkbytes && (!$expected || !preg_match("/^\\w{32}-\\w+$/", $expected))) {
        $return = md5_file($filename);
        if ($expected) {
            $expected = strtolower($expected);
            return ($expected === $return ? true : false);
        } else {
            return $return;
        }
    } else {
        $md5s = array();
        $handle = fopen($filename, 'rb');
        if ($handle === false) {
            return false;
        }
        while (!feof($handle)) {
            $buffer = fread($handle, $chunkbytes);
            $md5s[] = md5($buffer);
            unset($buffer);
        }
        fclose($handle);

        $concat = '';
        foreach ($md5s as $indx => $md5) {
            $concat .= hex2bin($md5);
        }
        $return = md5($concat) .'-'. count($md5s);
        if ($expected) {
            $expected = strtolower($expected);
            $matches = ($expected === $return ? true : false);
            if ($matches || $do_guess == false || strlen($expected) == 32) {
                return $matches;
            } else {
                // Guess the chunk size
                preg_match("/-(\\d+)$/", $expected, $match);
                $parts = $match[1];
                $min_chunk = ceil($filesize / $parts /1024/1024);
                $max_chunk =  floor($filesize / ($parts-1) /1024/1024);
                $found_match = false;
                for ($i = $min_chunk; $i <= $max_chunk; $i++) {
                    if (calculate_aws_etag($filename, $i) === $expected) {
                        $found_match = true;
                        break;
                    }
                }
                return $found_match;
            }
        } else {
            return $return;
        }
    }
}

The algorithm presented in this answer is accurate. i.e. You take the 128bit binary md5 digest of each part, concatenate them into a document, and hash that document.

There's something else worthy of note about the algorithm: If you copy, or do an in-place copy of your completed multipart-uploaded object (aka PUT-COPY), S3 will recompute the ETAG and use the simple version of the algorithm. i.e. the destination object will have an etag without the hyphen.

You've probably considered this already, but if your files are less than 5GB, and you already know their MD5s, and upload parallelization provides little to no benefit (e.g. you are streaming the upload from a slow network, or uploading from a slow disk), then you may also consider using a simple PUT instead of a multipart put, and pass your known Content-MD5 in your request headers -- amazon will fail the upload if they don't match. Keep in mind that you get charged for each UploadPart.

Furthermore, in some clients, passing a known MD5 for the input of a PUT operation will save the client from recomputing the MD5 during the transfer. In boto3 (python), you would use the ContentMD5 parameter of the client.put_object() method, for instance. If you omit the parameter, and you already knew the MD5, then the client would be wasting cycles computing it again before the transfer.

I have a solution for iOS and macOS without using external helpers like dd and xxd. I have just found it, so I report it as it is, planning to improve it at a later stage. For the moment, it relies on both Objective-C and Swift code. First of all, create this helper class in Objective-C:

AWS3MD5Hash.h

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface AWS3MD5Hash : NSObject

- (NSData *)dataFromFile:(FILE *)theFile startingOnByte:(UInt64)startByte length:(UInt64)length filePath:(NSString *)path singlePartSize:(NSUInteger)partSizeInMb;

- (NSData *)dataFromBigData:(NSData *)theData startingOnByte:(UInt64)startByte length:(UInt64)length;

- (NSData *)dataFromHexString:(NSString *)sourceString;

@end

NS_ASSUME_NONNULL_END

AWS3MD5Hash.m

#import "AWS3MD5Hash.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define SIZE 256

@implementation AWS3MD5Hash


- (NSData *)dataFromFile:(FILE *)theFile startingOnByte:(UInt64)startByte length:(UInt64)length filePath:(NSString *)path singlePartSize:(NSUInteger)partSizeInMb {


   char *buffer = malloc(length);


   NSURL *fileURL = [NSURL fileURLWithPath:path];
   NSNumber *fileSizeValue = nil;
   NSError *fileSizeError = nil;
   [fileURL getResourceValue:&fileSizeValue
                           forKey:NSURLFileSizeKey
                            error:&fileSizeError];

   NSInteger __unused result = fseek(theFile,startByte,SEEK_SET);

   if (result != 0) {
      free(buffer);
      return nil;
   }

   NSInteger result2 = fread(buffer, length, 1, theFile);

   NSUInteger difference = fileSizeValue.integerValue - startByte;

   NSData *toReturn;

   if (result2 == 0) {
       toReturn = [NSData dataWithBytes:buffer length:difference];
    } else {
       toReturn = [NSData dataWithBytes:buffer length:result2 * length];
    }

     free(buffer);

     return toReturn;
 }

 - (NSData *)dataFromBigData:(NSData *)theData startingOnByte:  (UInt64)startByte length:(UInt64)length {

   NSUInteger fileSizeValue = theData.length;
   NSData *subData;

   if (startByte + length > fileSizeValue) {
        subData = [theData subdataWithRange:NSMakeRange(startByte, fileSizeValue - startByte)];
    } else {
       subData = [theData subdataWithRange:NSMakeRange(startByte, length)];
    }

        return subData;
    }

- (NSData *)dataFromHexString:(NSString *)string {
    string = [string lowercaseString];
    NSMutableData *data= [NSMutableData new];
    unsigned char whole_byte;
    char byte_chars[3] = {'\0','\0','\0'};
    NSInteger i = 0;
    NSInteger length = string.length;
    while (i < length-1) {
       char c = [string characterAtIndex:i++];
       if (c < '0' || (c > '9' && c < 'a') || c > 'f')
           continue;
       byte_chars[0] = c;
       byte_chars[1] = [string characterAtIndex:i++];
       whole_byte = strtol(byte_chars, NULL, 16);
       [data appendBytes:&whole_byte length:1];
    }

        return data;
}


@end

Now create a plain swift file:

AWS Extensions.swift

import UIKit
import CommonCrypto

extension URL {

func calculateAWSS3MD5Hash(_ numberOfParts: UInt64) -> String? {


    do {

        var fileSize: UInt64!
        var calculatedPartSize: UInt64!

        let attr:NSDictionary? = try FileManager.default.attributesOfItem(atPath: self.path) as NSDictionary
        if let _attr = attr {
            fileSize = _attr.fileSize();
            if numberOfParts != 0 {



                let partSize = Double(fileSize / numberOfParts)

                var partSizeInMegabytes = Double(partSize / (1024.0 * 1024.0))



                partSizeInMegabytes = ceil(partSizeInMegabytes)

                calculatedPartSize = UInt64(partSizeInMegabytes)

                if calculatedPartSize % 2 != 0 {
                    calculatedPartSize += 1
                }

                if numberOfParts == 2 || numberOfParts == 3 { // Very important when there are 2 or 3 parts, in the majority of times
                                                              // the calculatedPartSize is already 8. In the remaining cases we force it.
                    calculatedPartSize = 8
                }


                if mainLogToggling {
                    print("The calculated part size is \(calculatedPartSize!) Megabytes")
                }

            }

        }

        if numberOfParts == 0 {

            let string = self.memoryFriendlyMd5Hash()
            return string

        }




        let hasher = AWS3MD5Hash.init()
        let file = fopen(self.path, "r")
        defer { let result = fclose(file)}


        var index: UInt64 = 0
        var bigString: String! = ""
        var data: Data!

        while autoreleasepool(invoking: {

                if index == (numberOfParts-1) {
                    if mainLogToggling {
                        //print("Siamo all'ultima linea.")
                    }
                }

                data = hasher.data(from: file!, startingOnByte: index * calculatedPartSize * 1024 * 1024, length: calculatedPartSize * 1024 * 1024, filePath: self.path, singlePartSize: UInt(calculatedPartSize))

                bigString = bigString + MD5.get(data: data) + "\n"

                index += 1

                if index == numberOfParts {
                    return false
                }
                return true

        }) {}

        let final = MD5.get(data :hasher.data(fromHexString: bigString)) + "-\(numberOfParts)"

        return final

    } catch {

    }

    return nil
}

   func memoryFriendlyMd5Hash() -> String? {

    let bufferSize = 1024 * 1024

    do {
        // Open file for reading:
        let file = try FileHandle(forReadingFrom: self)
        defer {
            file.closeFile()
        }

        // Create and initialize MD5 context:
        var context = CC_MD5_CTX()
        CC_MD5_Init(&context)

        // Read up to `bufferSize` bytes, until EOF is reached, and update MD5 context:
        while autoreleasepool(invoking: {
            let data = file.readData(ofLength: bufferSize)
            if data.count > 0 {
                data.withUnsafeBytes {
                    _ = CC_MD5_Update(&context, $0, numericCast(data.count))
                }
                return true // Continue
            } else {
                return false // End of file
            }
        }) { }

        // Compute the MD5 digest:
        var digest = Data(count: Int(CC_MD5_DIGEST_LENGTH))
        digest.withUnsafeMutableBytes {
            _ = CC_MD5_Final($0, &context)
        }
        let hexDigest = digest.map { String(format: "%02hhx", $0) }.joined()
        return hexDigest

    } catch {
        print("Cannot open file:", error.localizedDescription)
        return nil
    }
}

struct MD5 {

    static func get(data: Data) -> String {
        var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))

        let _ = data.withUnsafeBytes { bytes in
            CC_MD5(bytes, CC_LONG(data.count), &digest)
        }
        var digestHex = ""
        for index in 0..<Int(CC_MD5_DIGEST_LENGTH) {
            digestHex += String(format: "%02x", digest[index])
        }

        return digestHex
    }
    // The following is a memory friendly version
    static func get2(data: Data) -> String {

    var currentIndex = 0
    let bufferSize = 1024 * 1024
    //var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))

    // Create and initialize MD5 context:
    var context = CC_MD5_CTX()
    CC_MD5_Init(&context)


    while autoreleasepool(invoking: {
        var subData: Data!
        if (currentIndex + bufferSize) < data.count {
            subData = data.subdata(in: Range.init(NSMakeRange(currentIndex, bufferSize))!)
            currentIndex = currentIndex + bufferSize
        } else {
            subData = data.subdata(in: Range.init(NSMakeRange(currentIndex, data.count - currentIndex))!)
            currentIndex = currentIndex + (data.count - currentIndex)
        }
        if subData.count > 0 {
            subData.withUnsafeBytes {
                _ = CC_MD5_Update(&context, $0, numericCast(subData.count))
            }
            return true
        } else {
            return false
        }

    }) { }

    // Compute the MD5 digest:
    var digest = Data(count: Int(CC_MD5_DIGEST_LENGTH))
    digest.withUnsafeMutableBytes {
        _ = CC_MD5_Final($0, &context)
    }

    var digestHex = ""
    for index in 0..<Int(CC_MD5_DIGEST_LENGTH) {
        digestHex += String(format: "%02x", digest[index])
    }

    return digestHex

}
}

Now add:

#import "AWS3MD5Hash.h"

to your Objective-C Bridging header. You should be ok with this setup.

Example usage

To test this setup, you could be calling the following method inside the object that is in charge of handling the AWS connections:

func getMd5HashForFile() {


    let credentialProvider = AWSCognitoCredentialsProvider(regionType: AWSRegionType.USEast2, identityPoolId: "<INSERT_POOL_ID>")
    let configuration = AWSServiceConfiguration(region: AWSRegionType.APSoutheast2, credentialsProvider: credentialProvider)
    configuration?.timeoutIntervalForRequest = 3.0
    configuration?.timeoutIntervalForResource = 3.0

    AWSServiceManager.default().defaultServiceConfiguration = configuration

    AWSS3.register(with: configuration!, forKey: "defaultKey")
    let s3 = AWSS3.s3(forKey: "defaultKey")


    let headObjectRequest = AWSS3HeadObjectRequest()!
    headObjectRequest.bucket = "<NAME_OF_YOUR_BUCKET>"
    headObjectRequest.key = self.latestMapOnServer.key




    let _: AWSTask? = s3.headObject(headObjectRequest).continueOnSuccessWith { (awstask) -> Any? in

        let headObjectOutput: AWSS3HeadObjectOutput? = awstask.result

        var ETag = headObjectOutput?.eTag!
        // Here you should parse the returned Etag and extract the number of parts to provide to the helper function. Etags end with a "-" followed by the number of parts. If you don't see this format, then pass 0 as the number of parts.
        ETag = ETag!.replacingOccurrences(of: "\"", with: "")

        print("headObjectOutput.ETag \(ETag!)")

        let mapOnDiskUrl = self.getMapsDirectory().appendingPathComponent(self.latestMapOnDisk!)

        let hash = mapOnDiskUrl.calculateAWSS3MD5Hash(<Take the number of parts from the ETag returned by the server>)

        if hash == ETag {
            print("They are the same.")
        }

        print ("\(hash!)")

        return nil
    }



}

If the ETag returned by the server does not have "-" at the end of the ETag, just pass 0 to calculateAWSS3MD5Hash. Please comment if you encounter any problems. I am working on a swift only solution, I will update this answer as soon as I finish. Thanks

node.js implementation -

const fs = require('fs');
const crypto = require('crypto');

const chunk = 1024 * 1024 * 5; // 5MB

const md5 = data => crypto.createHash('md5').update(data).digest('hex');

const getEtagOfFile = (filePath) => {
  const stream = fs.readFileSync(filePath);
  if (stream.length < chunk) {
    return md5(stream);
  }
  const md5Chunks = [];
  const chunksNumber = Math.ceil(stream.length / chunk);
  for (let i = 0; i < chunksNumber; i++) {
    const chunkStream = stream.slice(i * chunk, (i + 1) * chunk);
    md5Chunks.push(md5(chunkStream));
  }

  return `${md5(Buffer.from(md5Chunks.join(''), 'hex'))}-${chunksNumber}`;
};

No,

Till now there is not solution to match normal file ETag and Multipart file ETag and MD5 of local file.

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!