Hello, updated version. Note that it still contains two unrelated hunks: the one I sent in "[PATCH] respect type when resolving MXF strong ref" and one simplifying klv_decode_ber_length, hope that makes reviewing not too inconvenient, if it does just review those before and I'll send a new patch after they are applied ;-) The key in AVFormatParameters is supposed to be a string for easier extensibility and easy way to specify on commandline mostly. The current format is just a hex string like "02045a...", 32 characters for AES-128 (the only format supported currently). IMO openssl should be replaced, it is too bloated for such a simple functionality but I'm not yet sure by what, not to mention that I am not up to date if it still has such an inconvenient license... Greetings, Reimar D?ffinger -------------- next part -------------- Index: libavformat/mxf.c =================================================================== --- libavformat/mxf.c (revision 7444) +++ libavformat/mxf.c (working copy) @@ -45,11 +45,13 @@ //#define DEBUG +#include <openssl/aes.h> #include "avformat.h" typedef uint8_t UID[16]; enum MXFMetadataSetType { + AnyType, MaterialPackage, SourcePackage, SourceClip, @@ -59,6 +61,9 @@ Descriptor, Track, EssenceContainerData, + CryptoFramework, + CryptoContext, + DMSegment, }; typedef struct MXFStructuralComponent { @@ -124,6 +129,27 @@ UID linked_package_uid; } MXFEssenceContainerData; +typedef struct MXFDMSegment { + UID uid; + enum MXFMetadataSetType type; + int *trackids; + int trackids_count; + UID framework_ul; +} MXFDMSegment; + +typedef struct MXFCryptoFramework { + UID uid; + enum MXFMetadataSetType type; + UID cryptocontext; +} MXFCryptoFramework; + +typedef struct MXFCryptoContext { + UID uid; + enum MXFMetadataSetType type; + UID contextid; + UID source_ul; +} MXFCryptoContext; + typedef struct { UID uid; enum MXFMetadataSetType type; @@ -141,6 +167,7 @@ MXFMetadataSet **metadata_sets; int metadata_sets_count; AVFormatContext *fc; + uint8_t cryptokey[16]; } MXFContext; typedef struct KLVPacket { @@ -173,6 +200,9 @@ /* partial keys to match */ static const uint8_t mxf_header_partition_pack_key[] = { 0x06,0x0e,0x2b,0x34,0x02,0x05,0x01,0x01,0x0d,0x01,0x02,0x01,0x01,0x02 }; static const uint8_t mxf_essence_element_key[] = { 0x06,0x0e,0x2b,0x34,0x01,0x02,0x01,0x01,0x0d,0x01,0x03,0x01 }; +/* complete keys to match */ +static const uint8_t mxf_encrypted_triplet_key[] = { 0x06,0x0e,0x2b,0x34,0x02,0x04,0x01,0x07,0x0d,0x01,0x03,0x01,0x02,0x7e,0x01,0x00 }; +static const uint8_t mxf_encrypted_essence_container[] = { 0x06,0x0E,0x2B,0x34,0x04,0x01,0x01,0x07,0x0D,0x01,0x03,0x01,0x02,0x0b,0x01,0x00 }; #define IS_KLV_KEY(x, y) (!memcmp(x, y, sizeof(y))) @@ -181,19 +211,14 @@ static int64_t klv_decode_ber_length(ByteIOContext *pb) { - int64_t size = 0; - uint8_t length = get_byte(pb); - int type = length >> 7; - - if (type) { /* long form */ - int bytes_num = length & 0x7f; - /* SMPTE 379M 5.3.4 guarantee that bytes_num must not exceed 8 bytes */ + uint64_t size = get_byte(pb); + if (size & 0x80) { /* long form */ + int bytes_num = size & 0x7f; if (bytes_num > 8) return -1; + size = 0; while (bytes_num--) size = size << 8 | get_byte(pb); - } else { - size = length & 0x7f; } return size; } @@ -210,6 +235,8 @@ { int i; + if (!IS_KLV_KEY(klv->key, mxf_essence_element_key)) + return -1; for (i = 0; i < s->nb_streams; i++) { MXFTrack *track = s->streams[i]->priv_data; /* SMPTE 379M 7.3 */ @@ -228,8 +255,7 @@ if (length > 61444) /* worst case PAL 1920 samples 8 channels */ return -1; - get_buffer(pb, buffer, length); - av_new_packet(pkt, length); + memcpy(buffer, pkt->data, length); data_ptr = pkt->data; end_ptr = buffer + length; buf_ptr = buffer + 4; /* skip SMPTE 331M header */ @@ -249,11 +275,58 @@ return 0; } +static int mxf_decrypt_triplet(AVFormatContext *s, AVPacket *pkt, KLVPacket *klv) +{ + static const uint8_t checkv[16] = {0x43, 0x48, 0x55, 0x4b, 0x43, 0x48, 0x55, 0x4b, 0x43, 0x48, 0x55, 0x4b, 0x43, 0x48, 0x55, 0x4b}; + MXFContext *mxf = s->priv_data; + ByteIOContext *pb = &s->pb; + offset_t end = url_ftell(pb) + klv->length; + uint64_t size; + uint64_t orig_size; + uint64_t plaintext_size; + uint8_t ivec[16]; + uint8_t tmpbuf[16]; + AES_KEY key; + AES_set_decrypt_key(mxf->cryptokey, 128, &key); + // crypto context + url_fskip(pb, klv_decode_ber_length(pb)); + // plaintext offset + klv_decode_ber_length(pb); + plaintext_size = get_be64(pb); + // source klv key + klv_decode_ber_length(pb); + get_buffer(pb, klv->key, 16); + // source size + klv_decode_ber_length(pb); + orig_size = get_be64(pb); + if (orig_size < plaintext_size) goto err_out; + // enc. code + size = klv_decode_ber_length(pb); + if (size < 32 || size - 32 < orig_size) goto err_out; + get_buffer(pb, ivec, 16); + get_buffer(pb, tmpbuf, 16); + AES_cbc_encrypt(tmpbuf, tmpbuf, 16, &key, ivec, AES_DECRYPT); + if (memcmp(tmpbuf, checkv, 16)) + av_log(s, AV_LOG_ERROR, "probably incorrect decryption key\n"); + size -= 32; + av_get_packet(pb, pkt, size); + AES_cbc_encrypt(&pkt->data[plaintext_size], &pkt->data[plaintext_size], + size - plaintext_size, &key, ivec, AES_DECRYPT); + pkt->size = orig_size; + url_fskip(pb, end - url_ftell(pb)); + return 0; + +err_out: + url_fskip(pb, end - url_ftell(pb)); + return -1; +} + static int mxf_read_packet(AVFormatContext *s, AVPacket *pkt) { KLVPacket klv; while (!url_feof(&s->pb)) { + int encrypted = 0; if (klv_read_packet(&klv, &s->pb) < 0) { av_log(s, AV_LOG_ERROR, "error reading KLV packet\n"); return -1; @@ -261,21 +334,29 @@ #ifdef DEBUG PRINT_KEY("read packet", klv.key); #endif + if (IS_KLV_KEY(klv.key, mxf_encrypted_triplet_key)) { + int res = mxf_decrypt_triplet(s, pkt, &klv); + if (res < 0) { + av_log(s, AV_LOG_ERROR, "invalid encoded triplet\n"); + continue; + } + encrypted = 1; + } if (IS_KLV_KEY(klv.key, mxf_essence_element_key)) { int index = mxf_get_stream_index(s, &klv); if (index < 0) { av_log(s, AV_LOG_ERROR, "error getting stream index\n"); - url_fskip(&s->pb, klv.length); return -1; } + if (!encrypted) + av_get_packet(&s->pb, pkt, klv.length); /* check for 8 channels AES3 element */ if (klv.key[12] == 0x06 && klv.key[13] == 0x01 && klv.key[14] == 0x10) { if (mxf_get_d10_aes3_packet(&s->pb, s->streams[index], pkt, klv.length) < 0) { av_log(s, AV_LOG_ERROR, "error reading D-10 aes3 frame\n"); return -1; } - } else - av_get_packet(&s->pb, pkt, klv.length); + } pkt->stream_index = index; return 0; } else @@ -666,6 +747,94 @@ return mxf_add_metadata_set(mxf, descriptor); } +static int mxf_read_metadata_dmsegment(MXFContext *mxf, KLVPacket *klv) { + ByteIOContext *pb = &mxf->fc->pb; + MXFDMSegment *dmsegment = av_mallocz(sizeof(*dmsegment)); + int bytes_read = 0; + + while (bytes_read < klv->length) { + int tag = get_be16(pb); + int size = get_be16(pb); /* KLV specified by 0x53 */ + + dprintf("tag 0x%04X, size %d\n", tag, size); + switch (tag) { + case 0x3C0A: + get_buffer(pb, dmsegment->uid, 16); + break; + case 0x6101: + get_buffer(pb, dmsegment->framework_ul, 16); + break; + case 0x6102: + dmsegment->trackids_count = get_be32(pb); + if (dmsegment->trackids_count >= UINT_MAX / sizeof(int)) + return -1; + dmsegment->trackids = av_malloc(dmsegment->trackids_count * sizeof(int)); + url_fskip(pb, 4); /* useless size of objects, always 4 according to specs */ + get_buffer(pb, (uint8_t *)dmsegment->trackids, dmsegment->trackids_count * sizeof(int)); + break; + default: + url_fskip(pb, size); + } + bytes_read += size + 4; + } + dmsegment->type = DMSegment; + return mxf_add_metadata_set(mxf, dmsegment); +} +static int mxf_read_metadata_cryptographic_framework(MXFContext *mxf, KLVPacket *klv) { + ByteIOContext *pb = &mxf->fc->pb; + MXFCryptoFramework *cryptoframework = av_mallocz(sizeof(*cryptoframework)); + int bytes_read = 0; + + while (bytes_read < klv->length) { + int tag = get_be16(pb); + int size = get_be16(pb); /* KLV specified by 0x53 */ + + dprintf("tag 0x%04X, size %d\n", tag, size); + switch (tag) { + case 0x3C0A: + get_buffer(pb, cryptoframework->uid, 16); + break; + case 0xFFFF: + get_buffer(pb, cryptoframework->cryptocontext, 16); + break; + default: + url_fskip(pb, size); + } + bytes_read += size + 4; + } + cryptoframework->type = CryptoFramework; + return mxf_add_metadata_set(mxf, cryptoframework); +} + +static int mxf_read_metadata_cryptographic_context(MXFContext *mxf, KLVPacket *klv) { + ByteIOContext *pb = &mxf->fc->pb; + MXFCryptoContext *cryptocontext = av_mallocz(sizeof(*cryptocontext)); + int bytes_read = 0; + + while (bytes_read < klv->length) { + int tag = get_be16(pb); + int size = get_be16(pb); /* KLV specified by 0x53 */ + + dprintf("tag 0x%04X, size %d\n", tag, size); + switch (tag) { + case 0x3C0A: + get_buffer(pb, cryptocontext->uid, 16); + break; + case 0xFFFE: + get_buffer(pb, cryptocontext->contextid, 16); + break; + case 0xFFFD: + get_buffer(pb, cryptocontext->source_ul, 16); + break; + default: + url_fskip(pb, size); + } + bytes_read += size + 4; + } + cryptocontext->type = CryptoContext; + return mxf_add_metadata_set(mxf, cryptocontext); +} + /* SMPTE RP224 http://www.smpte-ra.org/mdd/index.html */ static const MXFDataDefinitionUL mxf_data_definition_uls[] = { { { 0x06,0x0E,0x2B,0x34,0x04,0x01,0x01,0x01,0x01,0x03,0x02,0x02,0x01,0x00,0x00,0x00 }, CODEC_TYPE_VIDEO }, @@ -741,20 +910,42 @@ return uls->type; } -static void *mxf_resolve_strong_ref(MXFContext *mxf, UID *strong_ref) +static void *mxf_resolve_strong_ref(MXFContext *mxf, UID *strong_ref, enum MXFMetadataSetType type) { int i; if (!strong_ref) return NULL; for (i = 0; i < mxf->metadata_sets_count; i++) { - if (!memcmp(*strong_ref, mxf->metadata_sets[i]->uid, 16)) { + if (!memcmp(*strong_ref, mxf->metadata_sets[i]->uid, 16) && + (type == AnyType || mxf->metadata_sets[i]->type == type)) { return mxf->metadata_sets[i]; } } return NULL; } +static MXFCryptoContext *mxf_find_track_cryptocontext(MXFContext *mxf, int track_id) +{ + int i, j; + for (i = 0; i < mxf->metadata_sets_count; i++) { + if (mxf->metadata_sets[i]->type == DMSegment) { + MXFDMSegment *seg = (MXFDMSegment *)mxf->metadata_sets[i]; + for (j = 0; j < seg->trackids_count; j++) + if (seg->trackids[j] == track_id) break; + if (j < seg->trackids_count || seg->trackids_count == 0) { + MXFCryptoContext *cc = NULL; + MXFCryptoFramework *cf = mxf_resolve_strong_ref(mxf, seg->framework_ul, CryptoFramework); + if (cf) + cc = mxf_resolve_strong_ref(mxf, cf->cryptocontext, CryptoContext); + if (cc) + return cc; + } + } + } + return NULL; +} + static int mxf_parse_structural_metadata(MXFContext *mxf) { MXFPackage *material_package = NULL; @@ -764,14 +955,8 @@ dprintf("metadata sets count %d\n", mxf->metadata_sets_count); /* TODO: handle multiple material packages (OP3x) */ for (i = 0; i < mxf->packages_count; i++) { - if (!(temp_package = mxf_resolve_strong_ref(mxf, &mxf->packages_refs[i]))) { - av_log(mxf->fc, AV_LOG_ERROR, "could not resolve package strong ref\n"); - return -1; - } - if (temp_package->type == MaterialPackage) { - material_package = temp_package; - break; - } + material_package = mxf_resolve_strong_ref(mxf, &mxf->packages_refs[i], MaterialPackage); + if (material_package) break; } if (!material_package) { av_log(mxf->fc, AV_LOG_ERROR, "no material package found\n"); @@ -789,12 +974,12 @@ const MXFCodecUL *container_ul = NULL; AVStream *st; - if (!(material_track = mxf_resolve_strong_ref(mxf, &material_package->tracks_refs[i]))) { + if (!(material_track = mxf_resolve_strong_ref(mxf, &material_package->tracks_refs[i], Track))) { av_log(mxf->fc, AV_LOG_ERROR, "could not resolve material track strong ref\n"); continue; } - if (!(material_track->sequence = mxf_resolve_strong_ref(mxf, &material_track->sequence_ref))) { + if (!(material_track->sequence = mxf_resolve_strong_ref(mxf, &material_track->sequence_ref, Sequence))) { av_log(mxf->fc, AV_LOG_ERROR, "could not resolve material track sequence strong ref\n"); return -1; } @@ -802,15 +987,19 @@ /* TODO: handle multiple source clips */ for (j = 0; j < material_track->sequence->structural_components_count; j++) { /* TODO: handle timecode component */ - component = mxf_resolve_strong_ref(mxf, &material_track->sequence->structural_components_refs[j]); - if (!component || component->type != SourceClip) + component = mxf_resolve_strong_ref(mxf, &material_track->sequence->structural_components_refs[j], SourceClip); + if (!component) continue; for (k = 0; k < mxf->packages_count; k++) { - if (!(temp_package = mxf_resolve_strong_ref(mxf, &mxf->packages_refs[k]))) { + if (!(temp_package = mxf_resolve_strong_ref(mxf, &mxf->packages_refs[k], AnyType))) { av_log(mxf->fc, AV_LOG_ERROR, "could not resolve source track strong ref\n"); return -1; } + if (temp_package->type != MaterialPackage && temp_package->type != SourcePackage) { + av_log(mxf->fc, AV_LOG_ERROR, "source track strong ref resolved to bad type\n"); + return -1; + } if (!memcmp(temp_package->package_uid, component->source_package_uid, 16)) { source_package = temp_package; break; @@ -821,7 +1010,7 @@ break; } for (k = 0; k < source_package->tracks_count; k++) { - if (!(temp_track = mxf_resolve_strong_ref(mxf, &source_package->tracks_refs[k]))) { + if (!(temp_track = mxf_resolve_strong_ref(mxf, &source_package->tracks_refs[k], Track))) { av_log(mxf->fc, AV_LOG_ERROR, "could not resolve source track strong ref\n"); return -1; } @@ -846,7 +1035,7 @@ st->start_time = component->start_position; av_set_pts_info(st, 64, material_track->edit_rate.num, material_track->edit_rate.den); - if (!(source_track->sequence = mxf_resolve_strong_ref(mxf, &source_track->sequence_ref))) { + if (!(source_track->sequence = mxf_resolve_strong_ref(mxf, &source_track->sequence_ref, Sequence))) { av_log(mxf->fc, AV_LOG_ERROR, "could not resolve source track sequence strong ref\n"); return -1; } @@ -856,11 +1045,11 @@ #endif st->codec->codec_type = mxf_get_codec_type(mxf_data_definition_uls, &source_track->sequence->data_definition_ul); - source_package->descriptor = mxf_resolve_strong_ref(mxf, &source_package->descriptor_ref); + source_package->descriptor = mxf_resolve_strong_ref(mxf, &source_package->descriptor_ref, Descriptor); if (source_package->descriptor) { if (source_package->descriptor->type == MultipleDescriptor) { for (j = 0; j < source_package->descriptor->sub_descriptors_count; j++) { - MXFDescriptor *sub_descriptor = mxf_resolve_strong_ref(mxf, &source_package->descriptor->sub_descriptors_refs[j]); + MXFDescriptor *sub_descriptor = mxf_resolve_strong_ref(mxf, &source_package->descriptor->sub_descriptors_refs[j], Descriptor); if (!sub_descriptor) { av_log(mxf->fc, AV_LOG_ERROR, "could not resolve sub descriptor strong ref\n"); @@ -890,7 +1079,13 @@ st->codec->extradata_size = descriptor->extradata_size; } if (st->codec->codec_type == CODEC_TYPE_VIDEO) { - container_ul = mxf_get_codec_ul(mxf_picture_essence_container_uls, &descriptor->essence_container_ul); + UID *essence_container_ul = descriptor->essence_container_ul; + if (IS_KLV_KEY(essence_container_ul, mxf_encrypted_essence_container)) { + MXFCryptoContext *cc = mxf_find_track_cryptocontext(mxf, descriptor->linked_track_id); + if (cc) + essence_container_ul = cc->source_ul; + } + container_ul = mxf_get_codec_ul(mxf_picture_essence_container_uls, essence_container_ul); if (st->codec->codec_id == CODEC_ID_NONE) st->codec->codec_id = container_ul->id; st->codec->width = descriptor->width; @@ -945,9 +1140,33 @@ { { 0x06,0x0E,0x2B,0x34,0x02,0x53,0x01,0x01,0x0d,0x01,0x01,0x01,0x01,0x01,0x47,0x00 }, mxf_read_metadata_generic_descriptor }, /* AES3 */ { { 0x06,0x0E,0x2B,0x34,0x02,0x53,0x01,0x01,0x0d,0x01,0x01,0x01,0x01,0x01,0x3A,0x00 }, mxf_read_metadata_track }, /* Static Track */ { { 0x06,0x0E,0x2B,0x34,0x02,0x53,0x01,0x01,0x0d,0x01,0x01,0x01,0x01,0x01,0x3B,0x00 }, mxf_read_metadata_track }, /* Generic Track */ + { { 0x06,0x0E,0x2B,0x34,0x02,0x53,0x01,0x01,0x0d,0x01,0x01,0x01,0x01,0x01,0x41,0x00 }, mxf_read_metadata_dmsegment }, + { { 0x06,0x0E,0x2B,0x34,0x02,0x53,0x01,0x01,0x0d,0x01,0x04,0x01,0x02,0x01,0x00,0x00 }, mxf_read_metadata_cryptographic_framework }, + { { 0x06,0x0E,0x2B,0x34,0x02,0x53,0x01,0x01,0x0d,0x01,0x04,0x01,0x02,0x02,0x00,0x00 }, mxf_read_metadata_cryptographic_context }, { { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 }, NULL }, }; +/** + * \brief search for any 0x06 0x0E 0x2B 0x34 byte sequence + * \param key pointer to hold found key, must be able to hold at least 16 bytes + * if NULL, no further bytes will be read + * \return 1 if found, 0 if eof encountered + */ +static int mxf_read_sync_any(ByteIOContext *pb, uint8_t *key) { + uint32_t val = 0; + while (!url_feof(pb)) { + val = val << 8 | get_byte(pb); + if (val == 0x060E2B34) { + if (key) { + key[0] = 0x06; key[1] = 0x0E; key[2] = 0x2B; key[3] = 0x34; + get_buffer(pb, &key[4], 12); + } + return 1; + } + } + return 0; +} + static int mxf_read_sync(ByteIOContext *pb, const uint8_t *key, unsigned size) { int i, b; @@ -961,6 +1180,20 @@ return i == size; } +static uint8_t char2int(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return 0; +} + +static int mxf_parse_key(const char *str, uint8_t *key) { + if (strlen(str) > 32) return -1; + while (str[0] && str[1]) + *key++ = (char2int(*str++) << 4) | char2int(*str++); + return 0; +} + static int mxf_read_header(AVFormatContext *s, AVFormatParameters *ap) { MXFContext *mxf = s->priv_data; @@ -972,6 +1205,8 @@ } url_fseek(&s->pb, -14, SEEK_CUR); mxf->fc = s; + if (ap->key && mxf_parse_key(ap->key, mxf->cryptokey) < 0) + av_log(s, AV_LOG_ERROR, "could not parse crypto key\n"); while (!url_feof(&s->pb)) { const MXFMetadataReadTableEntry *function; @@ -982,7 +1217,8 @@ #ifdef DEBUG PRINT_KEY("read header", klv.key); #endif - if (IS_KLV_KEY(klv.key, mxf_essence_element_key)) { + if (IS_KLV_KEY(klv.key, mxf_essence_element_key) || + IS_KLV_KEY(klv.key, mxf_encrypted_triplet_key)) { /* FIXME avoid seek */ url_fseek(&s->pb, klv.offset, SEEK_SET); break; @@ -1023,6 +1259,9 @@ case MaterialPackage: av_freep(&((MXFPackage *)mxf->metadata_sets[i])->tracks_refs); break; + case DMSegment: + av_freep(&((MXFDMSegment *)mxf->metadata_sets[i])->trackids); + break; default: break; } @@ -1053,6 +1292,8 @@ static int mxf_read_seek(AVFormatContext *s, int stream_index, int64_t sample_time, int flags) { AVStream *st = s->streams[stream_index]; + uint8_t key[16]; + int found = 0; int64_t seconds; if (!s->bit_rate) @@ -1061,11 +1302,15 @@ sample_time = 0; seconds = av_rescale(sample_time, st->time_base.num, st->time_base.den); url_fseek(&s->pb, (s->bit_rate * seconds) >> 3, SEEK_SET); - if (!mxf_read_sync(&s->pb, mxf_essence_element_key, 12)) - return -1; + do { + if (!mxf_read_sync_any(&s->pb, key)) + return -1; + if (IS_KLV_KEY(key, mxf_encrypted_essence_container)) break; + if (IS_KLV_KEY(key, mxf_essence_element_key)) found++; + } while (found < 2); /* found KLV key */ - url_fseek(&s->pb, -12, SEEK_CUR); + url_fseek(&s->pb, -16, SEEK_CUR); av_update_cur_dts(s, st, sample_time); return 0; } Index: libavformat/avformat.h =================================================================== --- libavformat/avformat.h (revision 7444) +++ libavformat/avformat.h (working copy) @@ -25,8 +25,8 @@ extern "C" { #endif -#define LIBAVFORMAT_VERSION_INT ((51<<16)+(7<<8)+0) -#define LIBAVFORMAT_VERSION 51.7.0 +#define LIBAVFORMAT_VERSION_INT ((51<<16)+(8<<8)+0) +#define LIBAVFORMAT_VERSION 51.8.0 #define LIBAVFORMAT_BUILD LIBAVFORMAT_VERSION_INT #define LIBAVFORMAT_IDENT "Lavf" AV_STRINGIFY(LIBAVFORMAT_VERSION) @@ -126,6 +117,7 @@ int prealloced_context:1; enum CodecID video_codec_id; enum CodecID audio_codec_id; + const char *key; } AVFormatParameters; #define AVFMT_NOFILE 0x0001 /* no file should be opened */
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4