...

Source file src/go.mongodb.org/mongo-driver/x/mongo/driver/mongocrypt/mongocrypt.go

Documentation: go.mongodb.org/mongo-driver/x/mongo/driver/mongocrypt

     1  // Copyright (C) MongoDB, Inc. 2017-present.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License"); you may
     4  // not use this file except in compliance with the License. You may obtain
     5  // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
     6  
     7  //go:build cse
     8  // +build cse
     9  
    10  package mongocrypt
    11  
    12  // #cgo linux solaris darwin pkg-config: libmongocrypt
    13  // #cgo windows CFLAGS: -I"c:/libmongocrypt/include"
    14  // #cgo windows LDFLAGS: -lmongocrypt -Lc:/libmongocrypt/bin
    15  // #include <mongocrypt.h>
    16  // #include <stdlib.h>
    17  import "C"
    18  import (
    19  	"context"
    20  	"errors"
    21  	"fmt"
    22  	"net/http"
    23  	"unsafe"
    24  
    25  	"go.mongodb.org/mongo-driver/bson"
    26  	"go.mongodb.org/mongo-driver/internal/httputil"
    27  	"go.mongodb.org/mongo-driver/x/bsonx/bsoncore"
    28  	"go.mongodb.org/mongo-driver/x/mongo/driver/auth/creds"
    29  	"go.mongodb.org/mongo-driver/x/mongo/driver/mongocrypt/options"
    30  )
    31  
    32  type kmsProvider interface {
    33  	GetCredentialsDoc(context.Context) (bsoncore.Document, error)
    34  }
    35  
    36  type MongoCrypt struct {
    37  	wrapped      *C.mongocrypt_t
    38  	kmsProviders map[string]kmsProvider
    39  	httpClient   *http.Client
    40  }
    41  
    42  // Version returns the version string for the loaded libmongocrypt, or an empty string
    43  // if libmongocrypt was not loaded.
    44  func Version() string {
    45  	str := C.GoString(C.mongocrypt_version(nil))
    46  	return str
    47  }
    48  
    49  // NewMongoCrypt constructs a new MongoCrypt instance configured using the provided MongoCryptOptions.
    50  func NewMongoCrypt(opts *options.MongoCryptOptions) (*MongoCrypt, error) {
    51  	// create mongocrypt_t handle
    52  	wrapped := C.mongocrypt_new()
    53  	if wrapped == nil {
    54  		return nil, errors.New("could not create new mongocrypt object")
    55  	}
    56  	httpClient := opts.HTTPClient
    57  	if httpClient == nil {
    58  		httpClient = httputil.DefaultHTTPClient
    59  	}
    60  	kmsProviders := make(map[string]kmsProvider)
    61  	if needsKmsProvider(opts.KmsProviders, "gcp") {
    62  		kmsProviders["gcp"] = creds.NewGCPCredentialProvider(httpClient)
    63  	}
    64  	if needsKmsProvider(opts.KmsProviders, "aws") {
    65  		kmsProviders["aws"] = creds.NewAWSCredentialProvider(httpClient)
    66  	}
    67  	if needsKmsProvider(opts.KmsProviders, "azure") {
    68  		kmsProviders["azure"] = creds.NewAzureCredentialProvider(httpClient)
    69  	}
    70  	crypt := &MongoCrypt{
    71  		wrapped:      wrapped,
    72  		kmsProviders: kmsProviders,
    73  		httpClient:   httpClient,
    74  	}
    75  
    76  	// set options in mongocrypt
    77  	if err := crypt.setProviderOptions(opts.KmsProviders); err != nil {
    78  		return nil, err
    79  	}
    80  	if err := crypt.setLocalSchemaMap(opts.LocalSchemaMap); err != nil {
    81  		return nil, err
    82  	}
    83  	if err := crypt.setEncryptedFieldsMap(opts.EncryptedFieldsMap); err != nil {
    84  		return nil, err
    85  	}
    86  
    87  	if opts.BypassQueryAnalysis {
    88  		C.mongocrypt_setopt_bypass_query_analysis(wrapped)
    89  	}
    90  
    91  	// If loading the crypt_shared library isn't disabled, set the default library search path "$SYSTEM"
    92  	// and set a library override path if one was provided.
    93  	if !opts.CryptSharedLibDisabled {
    94  		systemStr := C.CString("$SYSTEM")
    95  		defer C.free(unsafe.Pointer(systemStr))
    96  		C.mongocrypt_setopt_append_crypt_shared_lib_search_path(crypt.wrapped, systemStr)
    97  
    98  		if opts.CryptSharedLibOverridePath != "" {
    99  			cryptSharedLibOverridePathStr := C.CString(opts.CryptSharedLibOverridePath)
   100  			defer C.free(unsafe.Pointer(cryptSharedLibOverridePathStr))
   101  			C.mongocrypt_setopt_set_crypt_shared_lib_path_override(crypt.wrapped, cryptSharedLibOverridePathStr)
   102  		}
   103  	}
   104  
   105  	C.mongocrypt_setopt_use_need_kms_credentials_state(crypt.wrapped)
   106  
   107  	// initialize handle
   108  	if !C.mongocrypt_init(crypt.wrapped) {
   109  		return nil, crypt.createErrorFromStatus()
   110  	}
   111  
   112  	return crypt, nil
   113  }
   114  
   115  // CreateEncryptionContext creates a Context to use for encryption.
   116  func (m *MongoCrypt) CreateEncryptionContext(db string, cmd bsoncore.Document) (*Context, error) {
   117  	ctx := newContext(C.mongocrypt_ctx_new(m.wrapped))
   118  	if ctx.wrapped == nil {
   119  		return nil, m.createErrorFromStatus()
   120  	}
   121  
   122  	cmdBinary := newBinaryFromBytes(cmd)
   123  	defer cmdBinary.close()
   124  	dbStr := C.CString(db)
   125  	defer C.free(unsafe.Pointer(dbStr))
   126  
   127  	if ok := C.mongocrypt_ctx_encrypt_init(ctx.wrapped, dbStr, C.int32_t(-1), cmdBinary.wrapped); !ok {
   128  		return nil, ctx.createErrorFromStatus()
   129  	}
   130  	return ctx, nil
   131  }
   132  
   133  // CreateDecryptionContext creates a Context to use for decryption.
   134  func (m *MongoCrypt) CreateDecryptionContext(cmd bsoncore.Document) (*Context, error) {
   135  	ctx := newContext(C.mongocrypt_ctx_new(m.wrapped))
   136  	if ctx.wrapped == nil {
   137  		return nil, m.createErrorFromStatus()
   138  	}
   139  
   140  	cmdBinary := newBinaryFromBytes(cmd)
   141  	defer cmdBinary.close()
   142  
   143  	if ok := C.mongocrypt_ctx_decrypt_init(ctx.wrapped, cmdBinary.wrapped); !ok {
   144  		return nil, ctx.createErrorFromStatus()
   145  	}
   146  	return ctx, nil
   147  }
   148  
   149  // lookupString returns a string for the value corresponding to the given key in the document.
   150  // if the key does not exist or the value is not a string, the empty string is returned.
   151  func lookupString(doc bsoncore.Document, key string) string {
   152  	strVal, _ := doc.Lookup(key).StringValueOK()
   153  	return strVal
   154  }
   155  
   156  func setAltName(ctx *Context, altName string) error {
   157  	// create document {"keyAltName": keyAltName}
   158  	idx, doc := bsoncore.AppendDocumentStart(nil)
   159  	doc = bsoncore.AppendStringElement(doc, "keyAltName", altName)
   160  	doc, _ = bsoncore.AppendDocumentEnd(doc, idx)
   161  
   162  	keyAltBinary := newBinaryFromBytes(doc)
   163  	defer keyAltBinary.close()
   164  
   165  	if ok := C.mongocrypt_ctx_setopt_key_alt_name(ctx.wrapped, keyAltBinary.wrapped); !ok {
   166  		return ctx.createErrorFromStatus()
   167  	}
   168  	return nil
   169  }
   170  
   171  func setKeyMaterial(ctx *Context, keyMaterial []byte) error {
   172  	// Create document {"keyMaterial": keyMaterial} using the generic binary sybtype 0x00.
   173  	idx, doc := bsoncore.AppendDocumentStart(nil)
   174  	doc = bsoncore.AppendBinaryElement(doc, "keyMaterial", 0x00, keyMaterial)
   175  	doc, err := bsoncore.AppendDocumentEnd(doc, idx)
   176  	if err != nil {
   177  		return err
   178  	}
   179  
   180  	keyMaterialBinary := newBinaryFromBytes(doc)
   181  	defer keyMaterialBinary.close()
   182  
   183  	if ok := C.mongocrypt_ctx_setopt_key_material(ctx.wrapped, keyMaterialBinary.wrapped); !ok {
   184  		return ctx.createErrorFromStatus()
   185  	}
   186  	return nil
   187  }
   188  
   189  func rewrapDataKey(ctx *Context, filter []byte) error {
   190  	filterBinary := newBinaryFromBytes(filter)
   191  	defer filterBinary.close()
   192  
   193  	if ok := C.mongocrypt_ctx_rewrap_many_datakey_init(ctx.wrapped, filterBinary.wrapped); !ok {
   194  		return ctx.createErrorFromStatus()
   195  	}
   196  	return nil
   197  }
   198  
   199  // CreateDataKeyContext creates a Context to use for creating a data key.
   200  func (m *MongoCrypt) CreateDataKeyContext(kmsProvider string, opts *options.DataKeyOptions) (*Context, error) {
   201  	ctx := newContext(C.mongocrypt_ctx_new(m.wrapped))
   202  	if ctx.wrapped == nil {
   203  		return nil, m.createErrorFromStatus()
   204  	}
   205  
   206  	// Create a masterKey document of the form { "provider": <provider string>, other options... }.
   207  	var masterKey bsoncore.Document
   208  	switch {
   209  	case opts.MasterKey != nil:
   210  		// The original key passed into the top-level API was already transformed into a raw BSON document and passed
   211  		// down to here, so we can modify it without copying. Remove the terminating byte to add the "provider" field.
   212  		masterKey = opts.MasterKey[:len(opts.MasterKey)-1]
   213  		masterKey = bsoncore.AppendStringElement(masterKey, "provider", kmsProvider)
   214  		masterKey, _ = bsoncore.AppendDocumentEnd(masterKey, 0)
   215  	default:
   216  		masterKey = bsoncore.NewDocumentBuilder().AppendString("provider", kmsProvider).Build()
   217  	}
   218  
   219  	masterKeyBinary := newBinaryFromBytes(masterKey)
   220  	defer masterKeyBinary.close()
   221  
   222  	if ok := C.mongocrypt_ctx_setopt_key_encryption_key(ctx.wrapped, masterKeyBinary.wrapped); !ok {
   223  		return nil, ctx.createErrorFromStatus()
   224  	}
   225  
   226  	for _, altName := range opts.KeyAltNames {
   227  		if err := setAltName(ctx, altName); err != nil {
   228  			return nil, err
   229  		}
   230  	}
   231  
   232  	if opts.KeyMaterial != nil {
   233  		if err := setKeyMaterial(ctx, opts.KeyMaterial); err != nil {
   234  			return nil, err
   235  		}
   236  	}
   237  
   238  	if ok := C.mongocrypt_ctx_datakey_init(ctx.wrapped); !ok {
   239  		return nil, ctx.createErrorFromStatus()
   240  	}
   241  	return ctx, nil
   242  }
   243  
   244  const (
   245  	IndexTypeUnindexed = 1
   246  	IndexTypeIndexed   = 2
   247  )
   248  
   249  // createExplicitEncryptionContext creates an explicit encryption context.
   250  func (m *MongoCrypt) createExplicitEncryptionContext(opts *options.ExplicitEncryptionOptions) (*Context, error) {
   251  	ctx := newContext(C.mongocrypt_ctx_new(m.wrapped))
   252  	if ctx.wrapped == nil {
   253  		return nil, m.createErrorFromStatus()
   254  	}
   255  
   256  	if opts.KeyID != nil {
   257  		keyIDBinary := newBinaryFromBytes(opts.KeyID.Data)
   258  		defer keyIDBinary.close()
   259  
   260  		if ok := C.mongocrypt_ctx_setopt_key_id(ctx.wrapped, keyIDBinary.wrapped); !ok {
   261  			return nil, ctx.createErrorFromStatus()
   262  		}
   263  	}
   264  	if opts.KeyAltName != nil {
   265  		if err := setAltName(ctx, *opts.KeyAltName); err != nil {
   266  			return nil, err
   267  		}
   268  	}
   269  
   270  	if opts.RangeOptions != nil {
   271  		idx, mongocryptDoc := bsoncore.AppendDocumentStart(nil)
   272  		if opts.RangeOptions.Min != nil {
   273  			mongocryptDoc = bsoncore.AppendValueElement(mongocryptDoc, "min", *opts.RangeOptions.Min)
   274  		}
   275  		if opts.RangeOptions.Max != nil {
   276  			mongocryptDoc = bsoncore.AppendValueElement(mongocryptDoc, "max", *opts.RangeOptions.Max)
   277  		}
   278  		if opts.RangeOptions.Precision != nil {
   279  			mongocryptDoc = bsoncore.AppendInt32Element(mongocryptDoc, "precision", *opts.RangeOptions.Precision)
   280  		}
   281  		mongocryptDoc = bsoncore.AppendInt64Element(mongocryptDoc, "sparsity", opts.RangeOptions.Sparsity)
   282  
   283  		mongocryptDoc, err := bsoncore.AppendDocumentEnd(mongocryptDoc, idx)
   284  		if err != nil {
   285  			return nil, err
   286  		}
   287  
   288  		mongocryptBinary := newBinaryFromBytes(mongocryptDoc)
   289  		defer mongocryptBinary.close()
   290  
   291  		if ok := C.mongocrypt_ctx_setopt_algorithm_range(ctx.wrapped, mongocryptBinary.wrapped); !ok {
   292  			return nil, ctx.createErrorFromStatus()
   293  		}
   294  	}
   295  
   296  	algoStr := C.CString(opts.Algorithm)
   297  	defer C.free(unsafe.Pointer(algoStr))
   298  
   299  	if ok := C.mongocrypt_ctx_setopt_algorithm(ctx.wrapped, algoStr, -1); !ok {
   300  		return nil, ctx.createErrorFromStatus()
   301  	}
   302  
   303  	if opts.QueryType != "" {
   304  		queryStr := C.CString(opts.QueryType)
   305  		defer C.free(unsafe.Pointer(queryStr))
   306  		if ok := C.mongocrypt_ctx_setopt_query_type(ctx.wrapped, queryStr, -1); !ok {
   307  			return nil, ctx.createErrorFromStatus()
   308  		}
   309  	}
   310  
   311  	if opts.ContentionFactor != nil {
   312  		if ok := C.mongocrypt_ctx_setopt_contention_factor(ctx.wrapped, C.int64_t(*opts.ContentionFactor)); !ok {
   313  			return nil, ctx.createErrorFromStatus()
   314  		}
   315  	}
   316  	return ctx, nil
   317  }
   318  
   319  // CreateExplicitEncryptionContext creates a Context to use for explicit encryption.
   320  func (m *MongoCrypt) CreateExplicitEncryptionContext(doc bsoncore.Document, opts *options.ExplicitEncryptionOptions) (*Context, error) {
   321  	ctx, err := m.createExplicitEncryptionContext(opts)
   322  	if err != nil {
   323  		return ctx, err
   324  	}
   325  	docBinary := newBinaryFromBytes(doc)
   326  	defer docBinary.close()
   327  	if ok := C.mongocrypt_ctx_explicit_encrypt_init(ctx.wrapped, docBinary.wrapped); !ok {
   328  		return nil, ctx.createErrorFromStatus()
   329  	}
   330  
   331  	return ctx, nil
   332  }
   333  
   334  // CreateExplicitEncryptionExpressionContext creates a Context to use for explicit encryption of an expression.
   335  func (m *MongoCrypt) CreateExplicitEncryptionExpressionContext(doc bsoncore.Document, opts *options.ExplicitEncryptionOptions) (*Context, error) {
   336  	ctx, err := m.createExplicitEncryptionContext(opts)
   337  	if err != nil {
   338  		return ctx, err
   339  	}
   340  	docBinary := newBinaryFromBytes(doc)
   341  	defer docBinary.close()
   342  	if ok := C.mongocrypt_ctx_explicit_encrypt_expression_init(ctx.wrapped, docBinary.wrapped); !ok {
   343  		return nil, ctx.createErrorFromStatus()
   344  	}
   345  
   346  	return ctx, nil
   347  }
   348  
   349  // CreateExplicitDecryptionContext creates a Context to use for explicit decryption.
   350  func (m *MongoCrypt) CreateExplicitDecryptionContext(doc bsoncore.Document) (*Context, error) {
   351  	ctx := newContext(C.mongocrypt_ctx_new(m.wrapped))
   352  	if ctx.wrapped == nil {
   353  		return nil, m.createErrorFromStatus()
   354  	}
   355  
   356  	docBinary := newBinaryFromBytes(doc)
   357  	defer docBinary.close()
   358  
   359  	if ok := C.mongocrypt_ctx_explicit_decrypt_init(ctx.wrapped, docBinary.wrapped); !ok {
   360  		return nil, ctx.createErrorFromStatus()
   361  	}
   362  	return ctx, nil
   363  }
   364  
   365  // CryptSharedLibVersion returns the version number for the loaded crypt_shared library, or 0 if the
   366  // crypt_shared library was not loaded.
   367  func (m *MongoCrypt) CryptSharedLibVersion() uint64 {
   368  	return uint64(C.mongocrypt_crypt_shared_lib_version(m.wrapped))
   369  }
   370  
   371  // CryptSharedLibVersionString returns the version string for the loaded crypt_shared library, or an
   372  // empty string if the crypt_shared library was not loaded.
   373  func (m *MongoCrypt) CryptSharedLibVersionString() string {
   374  	// Pass in a pointer for "len", but ignore the value because C.GoString can determine the string
   375  	// length without it.
   376  	len := C.uint(0)
   377  	str := C.GoString(C.mongocrypt_crypt_shared_lib_version_string(m.wrapped, &len))
   378  	return str
   379  }
   380  
   381  // Close cleans up any resources associated with the given MongoCrypt instance.
   382  func (m *MongoCrypt) Close() {
   383  	C.mongocrypt_destroy(m.wrapped)
   384  	if m.httpClient == httputil.DefaultHTTPClient {
   385  		httputil.CloseIdleHTTPConnections(m.httpClient)
   386  	}
   387  }
   388  
   389  // RewrapDataKeyContext create a Context to use for rewrapping a data key.
   390  func (m *MongoCrypt) RewrapDataKeyContext(filter []byte, opts *options.RewrapManyDataKeyOptions) (*Context, error) {
   391  	const masterKey = "masterKey"
   392  	const providerKey = "provider"
   393  
   394  	ctx := newContext(C.mongocrypt_ctx_new(m.wrapped))
   395  	if ctx.wrapped == nil {
   396  		return nil, m.createErrorFromStatus()
   397  	}
   398  
   399  	if opts.MasterKey != nil && opts.Provider == nil {
   400  		// Provider is nil, but MasterKey is set. This is an error.
   401  		return nil, fmt.Errorf("expected 'Provider' to be set to identify type of 'MasterKey'")
   402  	}
   403  
   404  	if opts.Provider != nil {
   405  		// If a provider has been specified, create an encryption key document for creating a data key or for rewrapping
   406  		// datakeys. If a new provider is not specified, then the filter portion of this logic returns the data as it
   407  		// exists in the collection.
   408  		idx, mongocryptDoc := bsoncore.AppendDocumentStart(nil)
   409  		mongocryptDoc = bsoncore.AppendStringElement(mongocryptDoc, providerKey, *opts.Provider)
   410  
   411  		if opts.MasterKey != nil {
   412  			mongocryptDoc = opts.MasterKey[:len(opts.MasterKey)-1]
   413  			mongocryptDoc = bsoncore.AppendStringElement(mongocryptDoc, providerKey, *opts.Provider)
   414  		}
   415  
   416  		mongocryptDoc, err := bsoncore.AppendDocumentEnd(mongocryptDoc, idx)
   417  		if err != nil {
   418  			return nil, err
   419  		}
   420  
   421  		mongocryptBinary := newBinaryFromBytes(mongocryptDoc)
   422  		defer mongocryptBinary.close()
   423  
   424  		// Add new masterKey to the mongocrypt context.
   425  		if ok := C.mongocrypt_ctx_setopt_key_encryption_key(ctx.wrapped, mongocryptBinary.wrapped); !ok {
   426  			return nil, ctx.createErrorFromStatus()
   427  		}
   428  	}
   429  
   430  	return ctx, rewrapDataKey(ctx, filter)
   431  }
   432  
   433  func (m *MongoCrypt) setProviderOptions(kmsProviders bsoncore.Document) error {
   434  	providersBinary := newBinaryFromBytes(kmsProviders)
   435  	defer providersBinary.close()
   436  
   437  	if ok := C.mongocrypt_setopt_kms_providers(m.wrapped, providersBinary.wrapped); !ok {
   438  		return m.createErrorFromStatus()
   439  	}
   440  	return nil
   441  }
   442  
   443  // setLocalSchemaMap sets the local schema map in mongocrypt.
   444  func (m *MongoCrypt) setLocalSchemaMap(schemaMap map[string]bsoncore.Document) error {
   445  	if len(schemaMap) == 0 {
   446  		return nil
   447  	}
   448  
   449  	// convert schema map to BSON document
   450  	schemaMapBSON, err := bson.Marshal(schemaMap)
   451  	if err != nil {
   452  		return fmt.Errorf("error marshalling SchemaMap: %v", err)
   453  	}
   454  
   455  	schemaMapBinary := newBinaryFromBytes(schemaMapBSON)
   456  	defer schemaMapBinary.close()
   457  
   458  	if ok := C.mongocrypt_setopt_schema_map(m.wrapped, schemaMapBinary.wrapped); !ok {
   459  		return m.createErrorFromStatus()
   460  	}
   461  	return nil
   462  }
   463  
   464  // setEncryptedFieldsMap sets the encryptedfields map in mongocrypt.
   465  func (m *MongoCrypt) setEncryptedFieldsMap(encryptedfieldsMap map[string]bsoncore.Document) error {
   466  	if len(encryptedfieldsMap) == 0 {
   467  		return nil
   468  	}
   469  
   470  	// convert encryptedfields map to BSON document
   471  	encryptedfieldsMapBSON, err := bson.Marshal(encryptedfieldsMap)
   472  	if err != nil {
   473  		return fmt.Errorf("error marshalling EncryptedFieldsMap: %v", err)
   474  	}
   475  
   476  	encryptedfieldsMapBinary := newBinaryFromBytes(encryptedfieldsMapBSON)
   477  	defer encryptedfieldsMapBinary.close()
   478  
   479  	if ok := C.mongocrypt_setopt_encrypted_field_config_map(m.wrapped, encryptedfieldsMapBinary.wrapped); !ok {
   480  		return m.createErrorFromStatus()
   481  	}
   482  	return nil
   483  }
   484  
   485  // createErrorFromStatus creates a new Error based on the status of the MongoCrypt instance.
   486  func (m *MongoCrypt) createErrorFromStatus() error {
   487  	status := C.mongocrypt_status_new()
   488  	defer C.mongocrypt_status_destroy(status)
   489  	C.mongocrypt_status(m.wrapped, status)
   490  	return errorFromStatus(status)
   491  }
   492  
   493  // needsKmsProvider returns true if provider was initially set to an empty document.
   494  // An empty document signals the driver to fetch credentials.
   495  func needsKmsProvider(kmsProviders bsoncore.Document, provider string) bool {
   496  	val, err := kmsProviders.LookupErr(provider)
   497  	if err != nil {
   498  		// KMS provider is not configured.
   499  		return false
   500  	}
   501  	doc, ok := val.DocumentOK()
   502  	// KMS provider is an empty document if the length is 5.
   503  	// An empty document contains 4 bytes of "\x00" and a null byte.
   504  	return ok && len(doc) == 5
   505  }
   506  
   507  // GetKmsProviders attempts to obtain credentials from environment.
   508  // It is expected to be called when a libmongocrypt context is in the mongocrypt.NeedKmsCredentials state.
   509  func (m *MongoCrypt) GetKmsProviders(ctx context.Context) (bsoncore.Document, error) {
   510  	builder := bsoncore.NewDocumentBuilder()
   511  	for k, p := range m.kmsProviders {
   512  		doc, err := p.GetCredentialsDoc(ctx)
   513  		if err != nil {
   514  			return nil, fmt.Errorf("unable to retrieve %s credentials: %w", k, err)
   515  		}
   516  		builder.AppendDocument(k, doc)
   517  	}
   518  	return builder.Build(), nil
   519  }
   520  

View as plain text