diff --git a/.github/references/ubuntu_22_04_clang_arm_manifest.json b/.github/references/ubuntu_22_04_clang_arm_manifest.json
index bcb495d3e9..e9772d44c1 100644
--- a/.github/references/ubuntu_22_04_clang_arm_manifest.json
+++ b/.github/references/ubuntu_22_04_clang_arm_manifest.json
@@ -86,6 +86,24 @@
"supportsDynamicProperties": "false",
"type": "org.apache.nifi.minifi.processors.ApplyTemplate"
},
+ {
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "failure",
+ "description": "Non-german flowfiles are routed here"
+ },
+ {
+ "name": "success",
+ "description": "All asciified flowfiles are routed here"
+ }
+ ],
+ "typeDescription": "RUST TEST PROCESSOR: This processor switches German characters with their ascii counterparts. (to test stream API)",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.processors.asciify_german.AsciifyGerman"
+ },
{
"propertyDescriptors": {
"Attribute name prefix": {
@@ -1132,6 +1150,75 @@
"supportsDynamicProperties": "false",
"type": "org.apache.nifi.minifi.processors.ConvertRecord"
},
+ {
+ "inputRequirement": "INPUT_FORBIDDEN",
+ "isSingleThreaded": "true",
+ "supportedRelationships": [],
+ "typeDescription": "RUST TEST PROCESSOR: For testing lazy logging",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.processors.count_actual_logging.CountActualLogging"
+ },
+ {
+ "propertyDescriptors": {
+ "Decryption Strategy": {
+ "name": "Decryption Strategy",
+ "description": "Strategy for writing files to success after decryption",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "DECRYPTED",
+ "allowableValues": [
+ {
+ "value": "DECRYPTED",
+ "displayName": "DECRYPTED"
+ },
+ {
+ "value": "PACKAGED",
+ "displayName": "PACKAGED"
+ }
+ ]
+ },
+ "Private Key Service": {
+ "typeProvidedByValue": {
+ "type": "minifi_pgp.controller_services.private_key_service.PGPPrivateKeyService",
+ "group": "minifi_pgp.controller_services.private_key_service",
+ "artifact": "minifi_pgp"
+ },
+ "name": "Private Key Service",
+ "description": "PGP Private Key Service for decrypting data encrypted with Public Key Encryption",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Symmetric Password": {
+ "name": "Symmetric Password",
+ "description": "Password used for decrypting data encrypted with Password-Based Encryption",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "true",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "failure",
+ "description": "Decryption Failed"
+ },
+ {
+ "name": "success",
+ "description": "Decryption Succeeded"
+ }
+ ],
+ "typeDescription": "Decrypt contents of OpenPGP messages. Using the Packaged Decryption Strategy preserves OpenPGP encoding to support subsequent signature verification.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_pgp.processors.decrypt_content.DecryptContentPGP"
+ },
{
"propertyDescriptors": {
"Max Buffer Age": {
@@ -1870,6 +1957,88 @@
"supportsDynamicProperties": "true",
"type": "org.apache.nifi.minifi.aws.processors.DeleteS3Object"
},
+ {
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "true",
+ "supportedRelationships": [
+ {
+ "name": "success",
+ "description": {}
+ }
+ ],
+ "typeDescription": "RUST TEST PROCESSOR: Duplicate text",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.processors.duplicate_text.DuplicateStreamText"
+ },
+ {
+ "propertyDescriptors": {
+ "File Encoding": {
+ "name": "File Encoding",
+ "description": "File Encoding for encryption",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "BINARY",
+ "allowableValues": [
+ {
+ "value": "ASCII",
+ "displayName": "ASCII"
+ },
+ {
+ "value": "BINARY",
+ "displayName": "BINARY"
+ }
+ ]
+ },
+ "Public Key Search": {
+ "name": "Public Key Search",
+ "description": "PGP Public Key Search will be used to match against the User ID or Key ID when formatted as uppercase hexadecimal string of 16 characters",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "FLOWFILE_ATTRIBUTES"
+ },
+ "Public Key Service": {
+ "typeProvidedByValue": {
+ "type": "minifi_pgp.controller_services.public_key_service.PGPPublicKeyService",
+ "group": "minifi_pgp.controller_services.public_key_service",
+ "artifact": "minifi_pgp"
+ },
+ "name": "Public Key Service",
+ "description": "PGP Public Key Service for encrypting data with Public Key Encryption",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Symmetric Password": {
+ "name": "Symmetric Password",
+ "description": "Password used for encrypting data with Password-Based Encryption",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "true",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "failure",
+ "description": "Encryption Failed"
+ },
+ {
+ "name": "success",
+ "description": "Encryption Succeeded"
+ }
+ ],
+ "typeDescription": "Encrypt contents using OpenPGP.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_pgp.processors.encrypt_content.EncryptContentPGP"
+ },
{
"propertyDescriptors": {
"Destination": {
@@ -3707,6 +3876,76 @@
"supportsDynamicProperties": "false",
"type": "org.apache.nifi.minifi.processors.GenerateFlowFile"
},
+ {
+ "propertyDescriptors": {
+ "Batch Size": {
+ "name": "Batch Size",
+ "description": "The number of FlowFiles to be transferred in each invocation",
+ "validator": "NON_NEGATIVE_INTEGER_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "1"
+ },
+ "Custom Text": {
+ "name": "Custom Text",
+ "description": "If Data Format is text and if Unique FlowFiles is false, then this custom text will be used as content of the generated FlowFiles and the File Size will be ignored. Finally, if Expression Language is used, evaluation will be performed only once per batch of generated FlowFiles",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "FLOWFILE_ATTRIBUTES"
+ },
+ "Data Format": {
+ "name": "Data Format",
+ "description": "Specifies whether the data should be Text or Binary",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "Binary",
+ "allowableValues": [
+ {
+ "value": "Text",
+ "displayName": "Text"
+ },
+ {
+ "value": "Binary",
+ "displayName": "Binary"
+ }
+ ]
+ },
+ "File Size": {
+ "name": "File Size",
+ "description": "The size of the file that will be used",
+ "validator": "DATA_SIZE_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "FLOWFILE_ATTRIBUTES",
+ "defaultValue": "1 kB"
+ },
+ "Unique FlowFiles": {
+ "name": "Unique FlowFiles",
+ "description": "If true, each FlowFile that is generated will be unique. If false, a random value will be generated and all FlowFiles will get the same content but this offers much higher throughput (but see the description of Custom Text for special non-random use cases)",
+ "validator": "BOOLEAN_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "true"
+ }
+ },
+ "inputRequirement": "INPUT_FORBIDDEN",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "success",
+ "description": "success"
+ }
+ ],
+ "typeDescription": "RUST TEST PROCESSOR: This processor creates FlowFiles with random data or custom content. GenerateFlowFile is useful for load testing, configuration, and simulation.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.processors.generate_flow_file.GenerateFlowFileRs"
+ },
{
"propertyDescriptors": {
"Bucket Name": {
@@ -3926,6 +4165,106 @@
"supportsDynamicProperties": "false",
"type": "org.apache.nifi.minifi.processors.GetFile"
},
+ {
+ "propertyDescriptors": {
+ "Batch Size": {
+ "name": "Batch Size",
+ "description": "The maximum number of files to pull in each iteration",
+ "validator": "NON_NEGATIVE_INTEGER_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "10"
+ },
+ "Ignore Hidden Files": {
+ "name": "Ignore Hidden Files",
+ "description": "Indicates whether or not hidden files should be ignored",
+ "validator": "BOOLEAN_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "true"
+ },
+ "Input Directory": {
+ "name": "Input Directory",
+ "description": "The input directory from which to pull files",
+ "validator": "NON_BLANK_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "FLOWFILE_ATTRIBUTES"
+ },
+ "Keep Source File": {
+ "name": "Keep Source File",
+ "description": "If true, the file is not deleted after it has been copied to the Content Repository",
+ "validator": "BOOLEAN_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "false"
+ },
+ "Maximum File Age": {
+ "name": "Maximum File Age",
+ "description": "The maximum age that a file must be in order to be pulled; any file older than this amount of time (according to last modification date) will be ignored",
+ "validator": "TIME_PERIOD_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Maximum File Size": {
+ "name": "Maximum File Size",
+ "description": "The maximum size that a file can be in order to be pulled",
+ "validator": "DATA_SIZE_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Minimum File Age": {
+ "name": "Minimum File Age",
+ "description": "The minimum age that a file must be in order to be pulled; any file younger than this amount of time (according to last modification date) will be ignored",
+ "validator": "TIME_PERIOD_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Minimum File Size": {
+ "name": "Minimum File Size",
+ "description": "The minimum size that a file can be in order to be pulled",
+ "validator": "DATA_SIZE_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Polling Interval": {
+ "name": "Polling Interval",
+ "description": "Indicates how long to wait before performing a directory listing",
+ "validator": "TIME_PERIOD_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Recurse Subdirectories": {
+ "name": "Recurse Subdirectories",
+ "description": "Indicates whether or not to pull files from subdirectories",
+ "validator": "BOOLEAN_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "true"
+ }
+ },
+ "inputRequirement": "INPUT_FORBIDDEN",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "success",
+ "description": "FlowFiles are transferred here after logging"
+ }
+ ],
+ "typeDescription": "RUST TEST PROCESSOR: Creates FlowFiles from files in a directory. MiNiFi will ignore files for which it doesn't have read permissions.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.processors.get_file.GetFileRs"
+ },
{
"propertyDescriptors": {
"Endpoint List": {
@@ -4426,6 +4765,84 @@
"supportsDynamicProperties": "false",
"type": "org.apache.nifi.minifi.processors.KamikazeProcessor"
},
+ {
+ "propertyDescriptors": {
+ "Schedule Behaviour": {
+ "name": "Schedule Behaviour",
+ "description": "What to do during the on_schedule method",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "ReturnOk",
+ "allowableValues": [
+ {
+ "value": "ReturnErr",
+ "displayName": "ReturnErr"
+ },
+ {
+ "value": "ReturnOk",
+ "displayName": "ReturnOk"
+ },
+ {
+ "value": "GetNotRegisteredProperty",
+ "displayName": "GetNotRegisteredProperty"
+ },
+ {
+ "value": "GetInvalidControllerService",
+ "displayName": "GetInvalidControllerService"
+ },
+ {
+ "value": "Panic",
+ "displayName": "Panic"
+ }
+ ]
+ },
+ "Trigger Behaviour": {
+ "name": "Trigger Behaviour",
+ "description": "What to do during the trigger method",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "ReturnOk",
+ "allowableValues": [
+ {
+ "value": "ReturnErr",
+ "displayName": "ReturnErr"
+ },
+ {
+ "value": "ReturnOk",
+ "displayName": "ReturnOk"
+ },
+ {
+ "value": "GetNotRegisteredProperty",
+ "displayName": "GetNotRegisteredProperty"
+ },
+ {
+ "value": "GetInvalidControllerService",
+ "displayName": "GetInvalidControllerService"
+ },
+ {
+ "value": "Panic",
+ "displayName": "Panic"
+ }
+ ]
+ }
+ },
+ "inputRequirement": "INPUT_ALLOWED",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "success",
+ "description": "success relationship"
+ }
+ ],
+ "typeDescription": "RUST TEST PROCESSOR: This processor can fail or panic in on_trigger and on_schedule calls based on configuration. Only for testing purposes.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.processors.kamikaze_processor.KamikazeProcessorRs"
+ },
{
"propertyDescriptors": {
"Azure Storage Credentials Service": {
@@ -5978,20 +6395,135 @@
"displayName": "debug"
},
{
- "value": "info",
- "displayName": "info"
+ "value": "info",
+ "displayName": "info"
+ },
+ {
+ "value": "warn",
+ "displayName": "warn"
+ },
+ {
+ "value": "error",
+ "displayName": "error"
+ },
+ {
+ "value": "critical",
+ "displayName": "critical"
+ }
+ ]
+ },
+ "Log Payload": {
+ "name": "Log Payload",
+ "description": "If true, the FlowFile's payload will be logged, in addition to its attributes. Otherwise, just the Attributes will be logged.",
+ "validator": "BOOLEAN_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "false"
+ },
+ "Log Prefix": {
+ "name": "Log Prefix",
+ "description": "Log prefix appended to the log lines. It helps to distinguish the output of multiple LogAttribute processors.",
+ "validator": "NON_BLANK_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Maximum Payload Line Length": {
+ "name": "Maximum Payload Line Length",
+ "description": "The logged payload will be broken into lines this long. 0 means no newlines will be added.",
+ "validator": "NON_NEGATIVE_INTEGER_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "0"
+ }
+ },
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "success",
+ "description": "success operational on the flow record"
+ }
+ ],
+ "typeDescription": "Logs attributes of flow files in the MiNiFi application log.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "org.apache.nifi.minifi.processors.LogAttribute"
+ },
+ {
+ "propertyDescriptors": {
+ "Attributes to Ignore": {
+ "name": "Attributes to Ignore",
+ "description": "A comma-separated list of Attributes to ignore. If not specified, no attributes will be ignored.",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Attributes to Log": {
+ "name": "Attributes to Log",
+ "description": "A comma-separated list of Attributes to Log. If not specified, all attributes will be logged.",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "FlowFiles To Log": {
+ "name": "FlowFiles To Log",
+ "description": "Number of flow files to log. If set to zero all flow files will be logged. Please note that this may block other threads from running if not used judiciously.",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "1"
+ },
+ "Hexencode Payload": {
+ "name": "Hexencode Payload",
+ "description": "If true, the FlowFile's payload will be logged in a hexencoded format",
+ "validator": "BOOLEAN_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "false"
+ },
+ "Log Level": {
+ "name": "Log Level",
+ "description": "The Log Level to use when logging the Attributes",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "Info",
+ "allowableValues": [
+ {
+ "value": "Trace",
+ "displayName": "Trace"
+ },
+ {
+ "value": "Debug",
+ "displayName": "Debug"
+ },
+ {
+ "value": "Info",
+ "displayName": "Info"
+ },
+ {
+ "value": "Warn",
+ "displayName": "Warn"
},
{
- "value": "warn",
- "displayName": "warn"
+ "value": "Error",
+ "displayName": "Error"
},
{
- "value": "error",
- "displayName": "error"
+ "value": "Critical",
+ "displayName": "Critical"
},
{
- "value": "critical",
- "displayName": "critical"
+ "value": "Off",
+ "displayName": "Off"
}
]
},
@@ -5999,7 +6531,7 @@
"name": "Log Payload",
"description": "If true, the FlowFile's payload will be logged, in addition to its attributes. Otherwise, just the Attributes will be logged.",
"validator": "BOOLEAN_VALIDATOR",
- "required": "false",
+ "required": "true",
"sensitive": "false",
"expressionLanguageScope": "NONE",
"defaultValue": "false"
@@ -6007,19 +6539,10 @@
"Log Prefix": {
"name": "Log Prefix",
"description": "Log prefix appended to the log lines. It helps to distinguish the output of multiple LogAttribute processors.",
- "validator": "NON_BLANK_VALIDATOR",
+ "validator": "VALID",
"required": "false",
"sensitive": "false",
"expressionLanguageScope": "NONE"
- },
- "Maximum Payload Line Length": {
- "name": "Maximum Payload Line Length",
- "description": "The logged payload will be broken into lines this long. 0 means no newlines will be added.",
- "validator": "NON_NEGATIVE_INTEGER_VALIDATOR",
- "required": "false",
- "sensitive": "false",
- "expressionLanguageScope": "NONE",
- "defaultValue": "0"
}
},
"inputRequirement": "INPUT_REQUIRED",
@@ -6027,13 +6550,13 @@
"supportedRelationships": [
{
"name": "success",
- "description": "success operational on the flow record"
+ "description": "FlowFiles are transferred here after logging"
}
],
- "typeDescription": "Logs attributes of flow files in the MiNiFi application log.",
+ "typeDescription": "RUST TEST PROCESSOR: Logs attributes of flow files in the MiNiFi application log.",
"supportsDynamicRelationships": "false",
"supportsDynamicProperties": "false",
- "type": "org.apache.nifi.minifi.processors.LogAttribute"
+ "type": "minifi_rs_playground.processors.log_attribute.LogAttributeRs"
},
{
"inputRequirement": "INPUT_ALLOWED",
@@ -6044,6 +6567,54 @@
"supportsDynamicProperties": "false",
"type": "org.apache.nifi.minifi.processors.LogOnDestructionProcessor"
},
+ {
+ "propertyDescriptors": {
+ "Lorem Ipsum Controller Service": {
+ "typeProvidedByValue": {
+ "type": "minifi_rs_playground.controller_services.lorem_ipsum_controller_service.LoremIpsumControllerService",
+ "group": "minifi_rs_playground.controller_services.lorem_ipsum_controller_service",
+ "artifact": "minifi_rs_playground"
+ },
+ "name": "Lorem Ipsum Controller Service",
+ "description": "Name of the lorem ipsum controller service",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Write Method": {
+ "name": "Write Method",
+ "description": "Which API to test",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "Buffer",
+ "allowableValues": [
+ {
+ "value": "Buffer",
+ "displayName": "Buffer"
+ },
+ {
+ "value": "Stream",
+ "displayName": "Stream"
+ }
+ ]
+ }
+ },
+ "inputRequirement": "INPUT_FORBIDDEN",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "success",
+ "description": "All flowfile are routed here"
+ }
+ ],
+ "typeDescription": "RUST TEST PROCESSOR: Processor to test Controller Service API",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.processors.lorem_ipsum_cs_user.LoremIpsumCSUser"
+ },
{
"propertyDescriptors": {
"After": {
@@ -7831,6 +8402,91 @@
"supportsDynamicProperties": "false",
"type": "org.apache.nifi.minifi.processors.PutFile"
},
+ {
+ "propertyDescriptors": {
+ "Conflict Resolution Strategy": {
+ "name": "Conflict Resolution Strategy",
+ "description": "Indicates what should happen when a file with the same name already exists in the output directory",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "fail",
+ "allowableValues": [
+ {
+ "value": "fail",
+ "displayName": "fail"
+ },
+ {
+ "value": "replace",
+ "displayName": "replace"
+ },
+ {
+ "value": "ignore",
+ "displayName": "ignore"
+ }
+ ]
+ },
+ "Create Missing Directories": {
+ "name": "Create Missing Directories",
+ "description": "If true, then missing destination directories will be created. If false, flowfiles are penalized and sent to failure.",
+ "validator": "BOOLEAN_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "true"
+ },
+ "Directory": {
+ "name": "Directory",
+ "description": "The output directory to which to put files",
+ "validator": "NON_BLANK_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "FLOWFILE_ATTRIBUTES",
+ "defaultValue": "."
+ },
+ "Directory Permissions": {
+ "name": "Directory Permissions",
+ "description": "Sets the permissions on the directories being created if 'Create Missing Directories' property is set. Must be an octal number (e.g. 644 or 0755). Not supported on Windows systems.",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Maximum File Count": {
+ "name": "Maximum File Count",
+ "description": "Specifies the maximum number of files that can exist in the output directory",
+ "validator": "NON_NEGATIVE_INTEGER_VALIDATOR",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Permissions": {
+ "name": "Permissions",
+ "description": "Sets the permissions on the output file to the value of this attribute. Must be an octal number (e.g. 644 or 0755). Not supported on Windows systems.",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "inputRequirement": "INPUT_REQUIRED",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [
+ {
+ "name": "failure",
+ "description": "Failed files (conflict, write failure, etc.) are transferred to failure"
+ },
+ {
+ "name": "success",
+ "description": "Flowfiles that are successfully written to a file are routed to this relationship"
+ }
+ ],
+ "typeDescription": "RUST TEST PROCESSOR: Writes the contents of a FlowFile to the local file system.",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.processors.put_file.PutFileRs"
+ },
{
"propertyDescriptors": {
"Bucket": {
@@ -10912,6 +11568,43 @@
"supportsDynamicRelationships": "false",
"supportsDynamicProperties": "false",
"type": "org.apache.nifi.minifi.api_testing.ZooProcessor"
+ },
+ {
+ "propertyDescriptors": {
+ "Can fly service": {
+ "typeProvidedByValue": {
+ "type": "minifi_rs_playground.controller_services.animal_controller_apis.CanFlyControllerApi",
+ "group": "minifi_rs_playground.controller_services.animal_controller_apis",
+ "artifact": "minifi_rs_playground"
+ },
+ "name": "Can fly service",
+ "description": "Test CanFlyService",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Number of Legs service": {
+ "typeProvidedByValue": {
+ "type": "minifi_rs_playground.controller_services.animal_controller_apis.NumberOfLegsControllerApi",
+ "group": "minifi_rs_playground.controller_services.animal_controller_apis",
+ "artifact": "minifi_rs_playground"
+ },
+ "name": "Number of Legs service",
+ "description": "Test NumberOfLegsService",
+ "validator": "VALID",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "inputRequirement": "INPUT_FORBIDDEN",
+ "isSingleThreaded": "false",
+ "supportedRelationships": [],
+ "typeDescription": "RUST TEST PROCESSOR: ZooProcessorRs",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.processors.zoo_processor.ZooProcessorRs"
}
],
"controllerServices": [
@@ -11100,6 +11793,43 @@
}
]
},
+ {
+ "propertyDescriptors": {
+ "Extra information": {
+ "name": "Extra information",
+ "description": "We need this to verify the casting was done correctly",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE"
+ },
+ "Has Jetpack": {
+ "name": "Has Jetpack",
+ "description": "Whether or not the dog has a jetpack",
+ "validator": "BOOLEAN_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "false"
+ }
+ },
+ "typeDescription": "RUST TEST CONTROLLER SERVICE: DogControllerRs",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.controller_services.dog_controller_service.DogControllerRs",
+ "providedApiImplementations": [
+ {
+ "artifact": "minifi_rs_playground",
+ "group": "minifi_rs_playground.controller_services.animal_controller_apis",
+ "type": "minifi_rs_playground.controller_services.animal_controller_apis.CanFlyControllerApi"
+ },
+ {
+ "artifact": "minifi_rs_playground",
+ "group": "minifi_rs_playground.controller_services.animal_controller_apis",
+ "type": "minifi_rs_playground.controller_services.animal_controller_apis.NumberOfLegsControllerApi"
+ }
+ ]
+ },
{
"typeDescription": "Test DuckController",
"supportsDynamicRelationships": "false",
@@ -11118,6 +11848,30 @@
}
]
},
+ {
+ "typeDescription": "RUST TEST CONTROLLER SERVICE: DuckControllerRs",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.controller_services.duck_controller_service.DuckControllerRs",
+ "providedApiImplementations": [
+ {
+ "artifact": "minifi_rs_playground",
+ "group": "minifi_rs_playground.controller_services.animal_controller_apis",
+ "type": "minifi_rs_playground.controller_services.animal_controller_apis.CanFlyControllerApi"
+ },
+ {
+ "artifact": "minifi_rs_playground",
+ "group": "minifi_rs_playground.controller_services.animal_controller_apis",
+ "type": "minifi_rs_playground.controller_services.animal_controller_apis.NumberOfLegsControllerApi"
+ }
+ ]
+ },
+ {
+ "typeDescription": "RUST TEST CONTROLLER SERVICE: Dummy Controller Service",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.controller_services.dummy_controller_service.DummyControllerService"
+ },
{
"propertyDescriptors": {
"API Key": {
@@ -11301,6 +12055,23 @@
}
]
},
+ {
+ "propertyDescriptors": {
+ "Length": {
+ "name": "Length",
+ "description": "How many words to generate",
+ "validator": "NON_NEGATIVE_INTEGER_VALIDATOR",
+ "required": "true",
+ "sensitive": "false",
+ "expressionLanguageScope": "NONE",
+ "defaultValue": "25"
+ }
+ },
+ "typeDescription": "RUST TEST CONTROLLER SERVICE: Holds generated lorem ipsum",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_rs_playground.controller_services.lorem_ipsum_controller_service.LoremIpsumControllerService"
+ },
{
"propertyDescriptors": {
"Default Prioritizer": {
@@ -11403,6 +12174,62 @@
"supportsDynamicProperties": "false",
"type": "org.apache.nifi.minifi.controllers.PersistentMapStateStorage"
},
+ {
+ "propertyDescriptors": {
+ "Key": {
+ "name": "Key",
+ "description": "Secret Key encoded in ASCII Armor",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "true",
+ "expressionLanguageScope": "NONE"
+ },
+ "Key File": {
+ "name": "Key File",
+ "description": "File path to PGP Secret Key encoded in binary or ASCII Armor",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "FLOWFILE_ATTRIBUTES"
+ },
+ "Key Passphrase": {
+ "name": "Key Passphrase",
+ "description": "Passphrase used for decrypting Private Keys",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "true",
+ "expressionLanguageScope": "NONE"
+ }
+ },
+ "typeDescription": "PGP Private Key Service provides Private Keys loaded from files or properties",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_pgp.controller_services.private_key_service.PGPPrivateKeyService"
+ },
+ {
+ "propertyDescriptors": {
+ "Keyring": {
+ "name": "Keyring",
+ "description": "PGP Keyring or Secret Key encoded in ASCII Armor",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "true",
+ "expressionLanguageScope": "NONE"
+ },
+ "Keyring File": {
+ "name": "Keyring File",
+ "description": "File path to PGP Keyring or Secret Key encoded in binary or ASCII Armor",
+ "validator": "VALID",
+ "required": "false",
+ "sensitive": "false",
+ "expressionLanguageScope": "FLOWFILE_ATTRIBUTES"
+ }
+ },
+ "typeDescription": "PGP Public Key Service providing Public Keys loaded from files",
+ "supportsDynamicRelationships": "false",
+ "supportsDynamicProperties": "false",
+ "type": "minifi_pgp.controller_services.public_key_service.PGPPublicKeyService"
+ },
{
"propertyDescriptors": {
"Proxy Server Host": {
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 221aa9fe28..c33fff8919 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ concurrency:
env:
DOCKER_CMAKE_FLAGS: -DDOCKER_VERIFY_THREAD=3 -DUSE_SHARED_LIBS= -DSTRICT_GSL_CHECKS=AUDIT -DCI_BUILD=ON -DENABLE_AWS=ON -DENABLE_KAFKA=ON -DENABLE_MQTT=ON -DENABLE_AZURE=ON -DENABLE_SQL=ON \
-DENABLE_SPLUNK=ON -DENABLE_GCP=ON -DENABLE_OPC=ON -DENABLE_PYTHON_SCRIPTING=ON -DENABLE_LUA_SCRIPTING=ON -DENABLE_KUBERNETES=ON -DENABLE_TEST_PROCESSORS=ON -DENABLE_PROMETHEUS=ON \
- -DENABLE_ELASTICSEARCH=ON -DENABLE_GRAFANA_LOKI=ON -DENABLE_COUCHBASE=ON -DENABLE_LLAMACPP=ON -DDOCKER_BUILD_ONLY=ON -DMINIFI_PERFORMANCE_TESTS=ON
+ -DENABLE_ELASTICSEARCH=ON -DENABLE_GRAFANA_LOKI=ON -DENABLE_COUCHBASE=ON -DENABLE_LLAMACPP=ON -DDOCKER_BUILD_ONLY=ON -DMINIFI_PERFORMANCE_TESTS=ON -DMINIFI_RUST=OFF
CCACHE_DIR: ${{ GITHUB.WORKSPACE }}/.ccache
jobs:
macos_xcode:
@@ -52,6 +52,7 @@ jobs:
-DENABLE_SFTP=ON
-DENABLE_SPLUNK=ON
-DENABLE_SQL=ON
+ -DMINIFI_RUST=ON
-DENABLE_TEST_PROCESSORS=ON
-DFORCE_COLORED_OUTPUT=OFF
-DLIBC_STATIC=OFF
@@ -161,6 +162,7 @@ jobs:
-DENABLE_SQL=ON
-DENABLE_TEST_PROCESSORS=ON
-DENABLE_WEL=ON
+ -DMINIFI_RUST=ON
-DFORCE_COLORED_OUTPUT=ON
-DINSTALLER_MERGE_MODULES=OFF
-DMINIFI_FAIL_ON_WARNINGS=ON
@@ -261,6 +263,7 @@ jobs:
-DENABLE_SPLUNK=ON
-DENABLE_SQL=ON
-DENABLE_SYSTEMD=ON
+ -DMINIFI_RUST=ON
-DENABLE_TEST_PROCESSORS=ON
-DFORCE_COLORED_OUTPUT=ON
-DMINIFI_FAIL_ON_WARNINGS=ON
@@ -552,8 +555,55 @@ jobs:
with:
name: behavex_output_modular
path: build/behavex_output_modular
+ rusty_docker_tests:
+ name: "Rusty Docker integration tests (x86_64)"
+ needs: docker_build
+ runs-on: ubuntu-24.04
+ timeout-minutes: 20
+ steps:
+ - id: checkout
+ uses: actions/checkout@v6
+ - id: run_cmake
+ name: Run CMake
+ run: |
+ mkdir build
+ cd build
+ cmake ${DOCKER_CMAKE_FLAGS} ..
+ - name: Download artifact
+ uses: actions/download-artifact@v8
+ with:
+ name: minifi_docker
+ path: build
+ - name: Load Docker image
+ run: |
+ docker load --input ./build/minifi_docker.tar | awk '{print $NF}' | xargs -I {} docker tag {} apacheminificpp:behave
+ - id: install_deps
+ name: Install dependencies for Docker Verify
+ run: |
+ sudo apt update
+ sudo apt install -y python3-virtualenv
+ - id: test
+ name: Docker Verify
+ working-directory: ./minifi_rust
+ run: cargo behave-alpine
+ - name: Test Reporter
+ if: always()
+ uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3
+ with:
+ name: Docker integration tests
+ path: minifi_rust/minifi_rs_behave/output/behave/*.xml
+ reporter: java-junit
+ only-summary: 'true'
+ list-tests: 'failed'
+ list-suites: 'failed'
+ - name: Upload artifact
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: minifi_rs_behave
+ path: minifi_rust/minifi_rs_behave/output
linters:
- name: "C++ lint + Shellcheck + Flake8"
+ name: "C++ lint + Shellcheck + Flake8 + Cargo fmt check + Clippy check"
runs-on: ubuntu-22.04-arm
timeout-minutes: 15
steps:
@@ -578,8 +628,20 @@ jobs:
continue-on-error: true
run: ./run_flake8.sh .
+ - id: cargo_fmt_check
+ name: Cargo fmt check
+ continue-on-error: true
+ run: cargo fmt --all --check
+ working-directory: minifi_rust
+
+ - id: clippy_check
+ name: Clippy check
+ continue-on-error: true
+ run: cargo clippy --workspace --all-targets -- -D warnings
+ working-directory: minifi_rust
+
- name: Check Linter Statuses
- if: steps.cpp_lint.outcome == 'failure' || steps.shellcheck.outcome == 'failure' || steps.flake8_check.outcome == 'failure'
+ if: steps.cpp_lint.outcome == 'failure' || steps.shellcheck.outcome == 'failure' || steps.flake8_check.outcome == 'failure' || steps.cargo_fmt_check.outcome == 'failure' || steps.clippy_check.outcome == 'failure'
run: |
echo "One or more linters failed. Failing the workflow."
exit 1
diff --git a/.github/workflows/create-release-artifacts.yml b/.github/workflows/create-release-artifacts.yml
index 53c91e34d5..cbf6ac7f3a 100644
--- a/.github/workflows/create-release-artifacts.yml
+++ b/.github/workflows/create-release-artifacts.yml
@@ -43,6 +43,16 @@ jobs:
with:
name: ${{ matrix.platform.rpm-artifact }}
path: build/nifi-minifi-cpp-*.rpm
+
+ - id: cargo
+ working-directory: minifi_rust
+ run: |
+ cargo build --release
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: minifi_rust_extensions
+ path: minifi_rust/target/release/*.so
windows_VS2022:
name: "Windows Server 2025 x86_64"
runs-on: windows-2025
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5af9c071c7..47993663f1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -849,3 +849,5 @@ if (MINIFI_ADVANCED_CODE_COVERAGE)
endif()
add_subdirectory(packaging)
+
+add_subdirectory(minifi_rust)
diff --git a/behave_framework/src/minifi_behave/steps/flow_building_steps.py b/behave_framework/src/minifi_behave/steps/flow_building_steps.py
index 40414a9e90..b880bbfbc0 100644
--- a/behave_framework/src/minifi_behave/steps/flow_building_steps.py
+++ b/behave_framework/src/minifi_behave/steps/flow_building_steps.py
@@ -301,6 +301,13 @@ def setup_controller_service(context: MinifiTestContext, service_name: str):
context.get_or_create_default_minifi_container().flow_definition.controller_services.append(controller_service)
+@given('a {class_name} controller service named "{service_name}" is set up and the "{property_name}" property set to "{property_value}"')
+def setup_controller_service_with_property(context: MinifiTestContext, class_name: str, service_name: str, property_name: str, property_value: str):
+ controller_service = ControllerService(class_name=class_name, service_name=service_name)
+ controller_service.add_property(property_name, property_value)
+ context.get_or_create_default_minifi_container().flow_definition.controller_services.append(controller_service)
+
+
@given('a {service_name} controller service is set up and the "{property_name}" property set to "{property_value}"')
def setup_controller_service_with_property(context: MinifiTestContext, service_name: str, property_name: str, property_value: str):
controller_service = ControllerService(class_name=service_name, service_name=service_name)
diff --git a/cmake/MiNiFiOptions.cmake b/cmake/MiNiFiOptions.cmake
index 5c9820d419..59993fe6d4 100644
--- a/cmake/MiNiFiOptions.cmake
+++ b/cmake/MiNiFiOptions.cmake
@@ -116,6 +116,7 @@ add_minifi_option(ENABLE_EXECUTE_PROCESS "Enable ExecuteProcess processor" OFF)
add_minifi_option(ENABLE_CONTROLLER "Enables the build of MiNiFi controller binary." ON)
add_minifi_option(ENABLE_LLAMACPP "Enables llama.cpp support." ON)
add_minifi_option(ENABLE_OPC "Instructs the build system to enable the OPC extension" ON)
+add_minifi_option(MINIFI_RUST "Enables the build of rust based extensions." OFF)
set_minifi_cache_variable(CUSTOM_MALLOC OFF "Overwrite malloc implementation.")
set_property(CACHE CUSTOM_MALLOC PROPERTY STRINGS "jemalloc" "mimalloc" "rpmalloc" OFF)
diff --git a/docker/rockylinux/Dockerfile b/docker/rockylinux/Dockerfile
index 7af9a3de23..019bfe4653 100644
--- a/docker/rockylinux/Dockerfile
+++ b/docker/rockylinux/Dockerfile
@@ -42,11 +42,12 @@ COPY . ${MINIFI_BASE_DIR}
# Install the system dependencies needed for a build
# ccache is in EPEL
RUN dnf -y install epel-release && dnf -y install gcc-toolset-14 gcc-toolset-14-libatomic-devel sudo git which make libarchive ccache ca-certificates perl patch bison flex libtool cmake rpmdevtools && \
+ if echo "$MINIFI_OPTIONS" | grep -q "MINIFI_RUST=ON"; then dnf -y install rust cargo clang; fi && \
if echo "$MINIFI_OPTIONS" | grep -q "ENABLE_ALL=ON\|ENABLE_PYTHON_SCRIPTING=ON\|ENABLE_OPC=ON"; then dnf -y --enablerepo=devel install python3-devel; fi && \
if echo "$MINIFI_OPTIONS" | grep -q "ENABLE_SFTP=ON" && [ "${DOCKER_SKIP_TESTS}" == "OFF" ]; then dnf -y install java-1.8.0-openjdk maven; fi
RUN cd $MINIFI_BASE_DIR && \
- ln -s /usr/bin/ccache /usr/lib64/ccache/c++
+ ln -sfn /usr/bin/ccache /usr/lib64/ccache/c++
# Setup minificpp user
RUN groupadd -g ${GID} ${USER} && useradd -g ${GID} ${USER} && \
diff --git a/minifi_rust/.cargo/config.toml b/minifi_rust/.cargo/config.toml
new file mode 100644
index 0000000000..b871040ed5
--- /dev/null
+++ b/minifi_rust/.cargo/config.toml
@@ -0,0 +1,19 @@
+[target.aarch64-unknown-linux-musl]
+rustflags = ["-C", "target-feature=-crt-static"]
+
+[target.x86_64-unknown-linux-musl]
+rustflags = ["-C", "target-feature=-crt-static"]
+
+[target.aarch64-apple-darwin]
+rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
+
+[target.x86_64-apple-darwin]
+rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
+
+[alias]
+behave = ["run", "--release", "--bin", "minifi_rs_behave"]
+behave-alpine = ["run", "--release", "--bin", "minifi_rs_behave", "--", "--alpine"]
+behave-debian = ["run", "--release", "--bin", "minifi_rs_behave", "--", "--debian"]
+
+[env]
+MINIFI_SDK_PATH="../"
diff --git a/minifi_rust/.dockerignore b/minifi_rust/.dockerignore
new file mode 100644
index 0000000000..aeb3a1bf07
--- /dev/null
+++ b/minifi_rust/.dockerignore
@@ -0,0 +1,12 @@
+.git
+.vscode
+.gitmodules
+.idea
+output
+features
+target/debug
+target/release/build
+target/release/deps
+target/release/.fingerprint
+target/release/examples
+target/release/incremental
diff --git a/minifi_rust/.gitignore b/minifi_rust/.gitignore
new file mode 100644
index 0000000000..eb4d6b29b9
--- /dev/null
+++ b/minifi_rust/.gitignore
@@ -0,0 +1,7 @@
+.idea
+output
+venv
+.venv
+target
+.DS_Store
+Cargo.lock
diff --git a/minifi_rust/CMakeLists.txt b/minifi_rust/CMakeLists.txt
new file mode 100644
index 0000000000..63b8a407e1
--- /dev/null
+++ b/minifi_rust/CMakeLists.txt
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+#
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+#
+
+if (NOT MINIFI_RUST)
+ return()
+endif()
+
+include(FetchContent)
+
+# Corrosion is a tool for integrating Rust into an existing CMake project
+FetchContent_Declare(Corrosion
+ URL https://github.com/corrosion-rs/corrosion/archive/refs/tags/v0.6.1.tar.gz
+ URL_HASH SHA256=e9e95b1ee2bad52681f347993fb1a5af5cce458c5ce8a2636c9e476e4babf8e3
+ SYSTEM)
+
+FetchContent_MakeAvailable(Corrosion)
+
+set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
+
+corrosion_import_crate(MANIFEST_PATH ${CMAKE_CURRENT_SOURCE_DIR}/Cargo.toml)
+
+if (NOT ENABLE_TEST_PROCESSORS)
+ set_target_properties(cargo-build_minifi_rs_playground PROPERTIES EXCLUDE_FROM_ALL TRUE)
+endif()
+
+include(CTest)
+
+add_test(
+ NAME cargo_tests
+ COMMAND cargo test
+ WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
+)
diff --git a/minifi_rust/Cargo.toml b/minifi_rust/Cargo.toml
new file mode 100644
index 0000000000..6dc306f11f
--- /dev/null
+++ b/minifi_rust/Cargo.toml
@@ -0,0 +1,9 @@
+[workspace]
+resolver = "3"
+members = ["minifi_native_sys", "minifi_native", "minifi_native_macros", "minifi_rs_behave", "extensions/*"]
+
+[profile.release]
+panic = "abort"
+
+[profile.dev]
+panic = "abort"
diff --git a/minifi_rust/LICENSE b/minifi_rust/LICENSE
new file mode 100644
index 0000000000..7968421e21
--- /dev/null
+++ b/minifi_rust/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2026 The Apache Software Foundation
+
+ 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.
diff --git a/minifi_rust/README.md b/minifi_rust/README.md
new file mode 100644
index 0000000000..6d8c9a01cf
--- /dev/null
+++ b/minifi_rust/README.md
@@ -0,0 +1,106 @@
+# MiNiFi Native Rust (tech preview)
+
+> ⚠️ **Tech Preview**
+>
+> Only the **Rust bindings** are a technology preview. The Rust source API
+> (both the `minifi-native` safe API and the `minifi-native-sys` FFI layer)
+> is still evolving, and we are **not yet committed to source-level backward
+> compatibility** for the Rust bindings — expect breaking changes in the
+> Rust API between MiNiFi C++ releases until the bindings are declared
+> stable.
+>
+> Under the hood, the bindings target the **stable MiNiFi C API**, which
+> **does** provide ABI backward compatibility. This means the compiled artifact
+> is unaffected by Rust-side churn: an extension built against an older
+> version of these bindings will keep loading into newer MiNiFi C++
+> releases. You only need to rebuild against the new bindings if you want
+> to pick up new Rust API features — upgrading the agent alone does not
+> force a rebuild.
+
+This project provides a safe, idiomatic, and high-performance Rust framework for building native extensions (processors) for [Apache NiFi MiNiFi C++](https://github.com/apache/nifi-minifi-cpp).
+
+It is designed to offer a robust developer experience, allowing you to write powerful and reliable data processing components in safe Rust.
+
+The framework completely encapsulates the unsafe C FFI (Foreign Function Interface) boundary, providing a pure Rust API that is fully mockable for unit testing.
+
+## Project Philosophy
+ - **Safety First**: Leverage Rust's compile-time guarantees to prevent common bugs like null pointers, buffer overflows, and data races.
+ - **Zero-Cost Abstraction**: The safe API wrapper is designed to compile down with zero runtime overhead compared to writing raw C code.
+ - **Ergonomics**: Provide a clean, idiomatic Rust API that is a pleasure to use. Developers should not need to think about unsafe code or C++ interoperability.
+ - **Testability**: Every component of a processor's logic should be unit-testable in a pure Rust environment, without needing a C++ host.
+ - **Cross Platform**: The library should work on all platforms that are supported by [Apache NiFi MiNiFi C++](https://github.com/apache/nifi-minifi-cpp).
+ - macOS (aarch64)
+ - Linux (x86_64, aarch64)
+ - Windows (x86_64)
+
+The project is structured as a Cargo workspace with a clear, layered architecture:
+
+### [minifi-native-sys](minifi_native_sys)
+Contains the raw, unsafe FFI bindings to the minifi-api.h C API.
+
+### [minifi-native](minifi_native)
+Provides the public, safe, and idiomatic Rust API. This is the crate that developers will use to build their processors.
+
+#### API Traits
+Pure Rust traits (Processor, ProcessSession, Logger, etc.) that define the abstract behavior of the MiNiFi environment.
+
+#### Higher level API
+Pure rust traits that simplify the requirements for a working processor. Pick the one that matches your processor's shape — the wrapper takes care of getting/creating the flowfile, wiring up streams, applying attributes, and transferring to the right relationship, so your code only needs to describe the transformation itself.
+ - [FlowFileTransform](minifi_native/src/api/processor_wrappers/flow_file_transform.rs)
+ - Consumes a single incoming flowfile, optionally rewrites its content (returned as an in-memory buffer) and/or adds attributes, then routes it to a relationship. Best for buffered, one-in / one-out transforms.
+ - [FlowFileStreamingTransform](minifi_native/src/api/processor_wrappers/flow_file_stream_transform.rs)
+ - Same one-in / one-out shape as `FlowFileTransform`, but you get both an `InputStream` and an `OutputStream` and write content incrementally. Use this when the payload is too large to hold in memory or when you want to stream data through as you process it.
+ - [FlowFileSource](minifi_native/src/api/processor_wrappers/flow_file_source.rs)
+ - Produces zero or more new flowfiles per trigger without consuming an input. Use this for processors that generate data (timers, pollers, external fetchers) — return an empty vec to yield when there is nothing to emit.
+ - [ComplexProcessor](minifi_native/src/api/processor_wrappers/complex_processor.rs)
+ - Escape hatch that hands you the raw `ProcessContext`, `ProcessSession`, and `Logger` — the same shape as MiNiFi C++'s `onTrigger` or NiFi Java's `Processor#onTrigger`. Use this when your processor doesn't fit the one-in / one-out / source molds: batching multiple flowfiles per trigger, custom routing logic, penalizing/rolling back, or anything that requires manual `session.get()` / `session.transfer()` bookkeeping.
+
+#### FFI Wrappers
+Concrete structs (CffiSession, CffiLogger, etc.) that implement the API traits by calling the unsafe functions from minifi-native-sys.
+
+#### Thread safety
+The trait system differentiates between thread-safe (&self) and single-threaded (&mut self) processors at compile time.
+
+#### Comprehensive Mocking
+A full suite of mock objects allows for fast and reliable unit testing of all processor logic.
+
+### [minifi_native_macros](minifi_native_macros)
+Helper crate that includes the procedural macros.
+
+### [minifi_rs_behave](minifi_rs_behave)
+Run the behave integration tests using MiNiFi's Docker framework. This will test the release artifacts against the latest released [MiNiFi native docker container](https://hub.docker.com/r/apache/nifi-minifi-cpp).
+There is a handy alias to initiate all behave tests.
+
+`cargo behave`
+
+## Creating an Extension
+Building an extension is straightforward. The framework provides a `declare_minifi_extension!` macro that automatically generates the C-compatible entry points and registers your components.
+
+```rust
+declare_minifi_extension!(
+ processors: [
+ (FlowFileSourceProcessorType, MultiThreaded, MyFlowFileSource),
+ (FlowFileTransformProcessorType, SingleThreaded, MyDataTransformer),
+ ],
+ controllers: [
+ MyCustomControllerService,
+ ]
+);
+```
+
+## Deployment
+Build your extension as a dynamic library: `cd extensions/your_extension && cargo build --release`.
+
+Locate the output artifact in `target/release/` (it will be a `.so` on Linux, `.dll` on Windows, or `.dylib` on macOS).
+
+Copy the library file into the MiNiFi C++ application's `extensions/` directory.
+
+Restart the MiNiFi C++ agent to automatically discover and load the new processors.
+
+## Included Extensions
+
+### [minifi_rs_playground](extensions/minifi_rs_playground)
+A concrete example and testing ground for extensions built using the minifi-native crate.
+- Demonstrates how to implement the Processor traits, define processor properties, and route to relationships.
+- Includes comprehensive unit tests using the pure-Rust mocking framework.
+- Includes integration testing that verifies the processor works as expected in a real, containerized MiNiFi environment.
diff --git a/minifi_rust/extensions/minifi_pgp/.cargo/config.toml b/minifi_rust/extensions/minifi_pgp/.cargo/config.toml
new file mode 100644
index 0000000000..cb8c02ddc4
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/.cargo/config.toml
@@ -0,0 +1,5 @@
+[target.aarch64-apple-darwin]
+rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
+
+[target.x86_64-apple-darwin]
+rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
diff --git a/minifi_rust/extensions/minifi_pgp/.gitignore b/minifi_rust/extensions/minifi_pgp/.gitignore
new file mode 100644
index 0000000000..f9f6d205fa
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/.gitignore
@@ -0,0 +1,7 @@
+target
+output
+features/.venv
+features/output
+integration_tests/features/.venv
+integration_tests/features/linux_so
+integration_tests/.venv
\ No newline at end of file
diff --git a/minifi_rust/extensions/minifi_pgp/Cargo.toml b/minifi_rust/extensions/minifi_pgp/Cargo.toml
new file mode 100644
index 0000000000..af2c6e1749
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "minifi_pgp"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+minifi_native = { path = "../../minifi_native" }
+strum_macros = "0.28.0"
+strum = "0.28.0"
+pgp = "0.20.0"
+rand = "0.8.6" # pgp 0.20.0 doesnt support >= 0.9 rand yet
+
diff --git a/minifi_rust/extensions/minifi_pgp/features/encrypt_decrypt.feature b/minifi_rust/extensions/minifi_pgp/features/encrypt_decrypt.feature
new file mode 100644
index 0000000000..3914e71308
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/features/encrypt_decrypt.feature
@@ -0,0 +1,54 @@
+@SUPPORTS_WINDOWS
+Feature: Test PGP extension's encryption and decryption capabilities
+
+ Background: The pgp library is successfully built on linux
+
+ Scenario: The pgp library is loaded into minifi
+ Given log property "logger.org::apache::nifi::minifi::core::extension::ExtensionManager" is set to "TRACE,stderr"
+ And log property "logger.org::apache::nifi::minifi::core::ClassLoader" is set to "TRACE,stderr"
+
+ When the MiNiFi instance starts up
+
+ Then the Minifi logs contain the following message: "Registering class 'EncryptContentPGP' at '/minifi_pgp'" in less than 10 seconds
+ And the Minifi logs contain the following message: "Registering class 'DecryptContentPGP' at '/minifi_pgp'" in less than 1 seconds
+ And the Minifi logs contain the following message: "Registering class 'PGPPublicKeyService' at '/minifi_pgp'" in less than 1 seconds
+ And the Minifi logs contain the following message: "Registering class 'PGPPrivateKeyService' at '/minifi_pgp'" in less than 1 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
+
+ Scenario: Encrypted for Alice but not for Bob
+ Given log property "logger.minifi_pgp::processors::decrypt_content::DecryptContentPGP" is set to "TRACE,stderr"
+ And log property "logger.minifi_pgp::processors::encrypt_content::EncryptContentPGP" is set to "TRACE,stderr"
+
+ And a GetFile processor with the "Input Directory" property set to "/tmp/input"
+ And an EncryptContentPGP processor with a PGPPublicKeyService is set up
+ And a DecryptContentPGP processor named DecryptAlice with a PGPPrivateKeyService is set up for Alice
+ And a DecryptContentPGP processor named DecryptBob with a PGPPrivateKeyService is set up for Bob
+ And a PutFile processor with the name "AliceSuccess"
+ And a PutFile processor with the name "BobFailure"
+
+ And these processor properties are set
+ | processor name | property name | property value |
+ | EncryptContentPGP | File Encoding | ASCII |
+ | EncryptContentPGP | Public Key Search | Alice |
+ | AliceSuccess | Directory | /tmp/output/alice_ok |
+ | BobFailure | Directory | /tmp/output/bob_fail |
+
+ And the processors are connected up as described here
+ | source name | relationship name | destination name |
+ | GetFile | success | EncryptContentPGP |
+ | EncryptContentPGP | success | DecryptAlice |
+ | EncryptContentPGP | success | DecryptBob |
+ | DecryptAlice | success | AliceSuccess |
+ | DecryptBob | failure | BobFailure |
+
+ And AliceSuccess's success relationship is auto-terminated
+ And BobFailure's success relationship is auto-terminated
+
+ And a directory at "/tmp/input" has a file "test_file.log" with the content "test content"
+
+ When the MiNiFi instance starts up
+
+ Then at least one file with the content "test content" is placed in the "/tmp/output/alice_ok" directory in less than 5 seconds
+ And an encrypted armored pgp file is placed in the "/tmp/output/bob_fail" directory in less than 5 seconds
+ And the Minifi logs do not contain errors
diff --git a/minifi_rust/extensions/minifi_pgp/features/environment.py b/minifi_rust/extensions/minifi_pgp/features/environment.py
new file mode 100644
index 0000000000..cdec449069
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/features/environment.py
@@ -0,0 +1,73 @@
+import os
+from typing import List
+
+from minifi_behave.containers.docker_image_builder import DockerImageBuilder
+from minifi_behave.core.hooks import common_after_scenario
+from minifi_behave.core.hooks import common_before_scenario, get_minifi_container_image
+from minifi_behave.core.minifi_test_context import MinifiTestContext
+
+
+def add_extension_to_minifi_container(
+ extension_name: str, possible_paths: List[str], context: MinifiTestContext
+):
+ new_container_name = f"apacheminificpp:{extension_name}"
+ is_windows = os.name == "nt"
+ if is_windows:
+ lib_filename = f"{extension_name}.dll"
+ container_extension_dir = (
+ "C:/Program Files/ApacheNiFiMiNiFi/nifi-minifi-cpp/extensions"
+ )
+ else:
+ lib_filename = f"lib{extension_name}.so"
+ container_extension_dir = "/opt/minifi/minifi-current/extensions/"
+
+ host_path = None
+ for path in possible_paths:
+ if os.path.exists(os.path.join(path, lib_filename)):
+ host_path = os.path.join(path, lib_filename)
+ break
+
+ assert host_path is not None, (
+ f"Could not find {lib_filename} in {[p for p in possible_paths]}"
+ )
+
+ with open(host_path, "rb") as f:
+ lib_content = f.read()
+
+ base_img = get_minifi_container_image()
+
+ if is_windows:
+ dockerfile = f"""
+FROM {base_img}
+COPY ["{lib_filename}", "{container_extension_dir}/{lib_filename}"]
+"""
+ else:
+ dockerfile = f"""
+FROM {base_img}
+COPY --chown=minificpp:minificpp {lib_filename} {container_extension_dir}
+RUN chmod 755 {container_extension_dir}{lib_filename}
+"""
+
+ builder = DockerImageBuilder(
+ image_tag=new_container_name,
+ dockerfile_content=dockerfile,
+ files_on_context={lib_filename: lib_content},
+ )
+
+ builder.build()
+ return new_container_name
+
+
+def before_all(context):
+ dir_path = os.path.dirname(os.path.realpath(__file__))
+ build_path = os.path.normpath(os.path.join(dir_path, "../../../target/release/"))
+ add_extension_to_minifi_container("minifi_pgp", [build_path], context)
+
+
+def before_scenario(context, scenario):
+ context.minifi_container_image = "apacheminificpp:minifi_pgp"
+ common_before_scenario(context, scenario)
+
+
+def after_scenario(context, scenario):
+ common_after_scenario(context, scenario)
diff --git a/minifi_rust/extensions/minifi_pgp/features/steps/steps.py b/minifi_rust/extensions/minifi_pgp/features/steps/steps.py
new file mode 100644
index 0000000000..b2a4d6e632
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/features/steps/steps.py
@@ -0,0 +1,100 @@
+import os
+from pathlib import Path
+
+import humanfriendly
+from behave import step, then
+
+from minifi_behave.steps import checking_steps # noqa: F401
+from minifi_behave.steps import configuration_steps # noqa: F401
+from minifi_behave.steps import core_steps # noqa: F401
+from minifi_behave.steps import flow_building_steps # noqa: F401
+from minifi_behave.core.helpers import wait_for_condition
+from minifi_behave.core.minifi_test_context import MinifiTestContext
+from minifi_behave.minifi.controller_service import ControllerService
+from minifi_behave.minifi.processor import Processor
+
+
+@step("an EncryptContentPGP processor with a PGPPublicKeyService is set up")
+def step_encrypt_content_with_service(context: MinifiTestContext):
+ dir_path = os.path.dirname(os.path.realpath(__file__))
+
+ public_key_service = ControllerService(
+ class_name="PGPPublicKeyService", service_name="my_public_keys"
+ )
+ alice_public_key = Path(f"{dir_path}/../../test_keys/keyring.asc").read_text()
+ public_key_service.add_property("Keyring", alice_public_key)
+ context.get_or_create_default_minifi_container().flow_definition.controller_services.append(
+ public_key_service
+ )
+
+ processor = Processor("EncryptContentPGP", "EncryptContentPGP")
+ processor.add_property("Public Key Service", "my_public_keys")
+ context.get_or_create_default_minifi_container().flow_definition.processors.append(
+ processor
+ )
+
+
+@step(
+ "a DecryptContentPGP processor named DecryptAlice with a PGPPrivateKeyService is set up for Alice"
+)
+def step_decrypt_content_for_alice(context: MinifiTestContext):
+ dir_path = os.path.dirname(os.path.realpath(__file__))
+
+ private_key_service = ControllerService(
+ class_name="PGPPrivateKeyService", service_name="alice_private_key"
+ )
+ alice_private_key = Path(
+ f"{dir_path}/../../test_keys/alice_private.asc"
+ ).read_text()
+ private_key_service.add_property("Key", alice_private_key)
+ private_key_service.add_property("Key Passphrase", "whiterabbit")
+ context.get_or_create_default_minifi_container().flow_definition.controller_services.append(
+ private_key_service
+ )
+
+ processor = Processor("DecryptContentPGP", "DecryptAlice")
+ processor.add_property("Private Key Service", "alice_private_key")
+ context.get_or_create_default_minifi_container().flow_definition.processors.append(
+ processor
+ )
+
+
+@step(
+ "a DecryptContentPGP processor named DecryptBob with a PGPPrivateKeyService is set up for Bob"
+)
+def step_decrypt_content_for_bob(context: MinifiTestContext):
+ dir_path = os.path.dirname(os.path.realpath(__file__))
+
+ private_key_service = ControllerService(
+ class_name="PGPPrivateKeyService", service_name="bob_private_key"
+ )
+ bob_private_key = Path(f"{dir_path}/../../test_keys/bob_private.asc").read_text()
+ private_key_service.add_property("Key", bob_private_key)
+ context.get_or_create_default_minifi_container().flow_definition.controller_services.append(
+ private_key_service
+ )
+
+ processor = Processor("DecryptContentPGP", "DecryptBob")
+ processor.add_property("Private Key Service", "bob_private_key")
+ context.get_or_create_default_minifi_container().flow_definition.processors.append(
+ processor
+ )
+
+
+@then(
+ 'an encrypted armored pgp file is placed in the "{directory}" directory in less than {duration}'
+)
+def then_armored_pgp_file_in_dir(
+ context: MinifiTestContext, directory: str, duration: str
+):
+ duration_seconds = humanfriendly.parse_timespan(duration)
+ assert wait_for_condition(
+ condition=lambda: (
+ context.get_or_create_default_minifi_container().directory_contains_file_with_regex(
+ directory, "-----BEGIN PGP MESSAGE-----"
+ )
+ ),
+ timeout_seconds=duration_seconds,
+ bail_condition=lambda: False,
+ context=context,
+ )
diff --git a/minifi_rust/extensions/minifi_pgp/minifi_pgp.md b/minifi_rust/extensions/minifi_pgp/minifi_pgp.md
new file mode 100644
index 0000000000..f2d9cbeab7
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/minifi_pgp.md
@@ -0,0 +1,120 @@
+
+
+## Table of Contents
+
+### Processors
+
+- [DecryptContentPGP](#DecryptContentPGP)
+- [EncryptContentPGP](#EncryptContentPGP)
+### Controller Services
+
+- [PGPPrivateKeyService](#PGPPrivateKeyService)
+- [PGPPublicKeyService](#PGPPublicKeyService)
+
+
+## DecryptContentPGP
+
+### Description
+
+Decrypt contents of OpenPGP messages. Using the Packaged Decryption Strategy preserves OpenPGP encoding to support subsequent signature verification.
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|---------------------|---------------|------------------------|-------------------------------------------------------------------------------------------------------------|
+| Decryption Strategy | DECRYPTED | DECRYPTED PACKAGED | Strategy for writing files to success after decryption |
+| Symmetric Password | | | Password used for decrypting data encrypted with Password-Based Encryption **Sensitive Property: true** |
+| Private Key Service | | | PGP Private Key Service for decrypting data encrypted with Public Key Encryption |
+
+### Relationships
+
+| Name | Description |
+|---------|----------------------|
+| success | Decryption Succeeded |
+| failure | Decryption Failed |
+
+### Output Attributes
+
+| Attribute | Relationship | Description |
+|---------------------------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| pgp.literal.data.filename | success | Filename from decrypted Literal Data (Note that OpenPGP signatures do not include the formatting octet, the file name, and the date field of the Literal Data packet in a signature hash; therefore, those fields are not protected against tampering in a signed document. Therefore a lot of implementation omit these inherently malleable metadata) |
+| pgp.literal.data.modified | success | Modified Date from decrypted Literal Data (Note that OpenPGP signatures do not include the formatting octet, the file name, and the date field of the Literal Data packet in a signature hash; therefore, those fields are not protected against tampering in a signed document. Therefore a lot of implementation omit these inherently malleable metadata) |
+
+
+## EncryptContentPGP
+
+### Description
+
+Encrypt contents using OpenPGP.
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|--------------------|---------------|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **File Encoding** | BINARY | ASCII BINARY | File Encoding for encryption |
+| Symmetric Password | | | Password used for encrypting data with Password-Based Encryption **Sensitive Property: true** |
+| Public Key Search | | | PGP Public Key Search will be used to match against the User ID or Key ID when formatted as uppercase hexadecimal string of 16 characters **Supports Expression Language: true** |
+| Public Key Service | | | PGP Public Key Service for encrypting data with Public Key Encryption |
+
+### Relationships
+
+| Name | Description |
+|---------|----------------------|
+| success | Encryption Succeeded |
+| failure | Encryption Failed |
+
+### Output Attributes
+
+| Attribute | Relationship | Description |
+|-------------------|--------------|---------------|
+| pgp.file.encoding | success | File Encoding |
+
+
+## PGPPrivateKeyService
+
+### Description
+
+PGP Private Key Service provides Private Keys loaded from files or properties
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|----------------|---------------|------------------|---------------------------------------------------------------------------------------------------------|
+| Key File | | | File path to PGP Secret Key encoded in binary or ASCII Armor **Supports Expression Language: true** |
+| Key | | | Secret Key encoded in ASCII Armor **Sensitive Property: true** |
+| Key Passphrase | | | Passphrase used for decrypting Private Keys **Sensitive Property: true** |
+
+
+## PGPPublicKeyService
+
+### Description
+
+PGP Public Key Service providing Public Keys loaded from files
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|--------------|---------------|------------------|--------------------------------------------------------------------------------------------------------------------|
+| Keyring File | | | File path to PGP Keyring or Secret Key encoded in binary or ASCII Armor **Supports Expression Language: true** |
+| Keyring | | | PGP Keyring or Secret Key encoded in ASCII Armor **Sensitive Property: true** |
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/key_lookup.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/key_lookup.rs
new file mode 100644
index 0000000000..cff723a718
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/key_lookup.rs
@@ -0,0 +1,56 @@
+use pgp::composed::SignedKeyDetails;
+use pgp::types::KeyId;
+
+/// Returns true when `target_id` matches either:
+/// - the key's Key ID formatted as 16-character hex (case-insensitive), or
+/// - any of its User IDs as a case-insensitive substring match.
+pub(crate) fn key_matches(key_id: &KeyId, details: &SignedKeyDetails, target_id: &str) -> bool {
+ let target = target_id.trim();
+ if target.is_empty() {
+ return false;
+ }
+
+ if key_id.to_string().eq_ignore_ascii_case(target) {
+ return true;
+ }
+
+ let target_lower = target.to_ascii_lowercase();
+ details.users.iter().any(|user| {
+ user.id
+ .as_str()
+ .map(|user_id| user_id.to_ascii_lowercase().contains(&target_lower))
+ .unwrap_or(false)
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn key_id_from_hex(hex: &str) -> KeyId {
+ let mut bytes = [0u8; 8];
+ for (i, chunk) in hex.as_bytes().chunks(2).take(8).enumerate() {
+ bytes[i] = u8::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap();
+ }
+ KeyId::from(bytes)
+ }
+
+ #[test]
+ fn empty_target_never_matches() {
+ let details = SignedKeyDetails::new(vec![], vec![], vec![], vec![]);
+ let key_id = key_id_from_hex("1122334455667788");
+ assert!(!key_matches(&key_id, &details, ""));
+ assert!(!key_matches(&key_id, &details, " "));
+ }
+
+ #[test]
+ fn matches_key_id_case_insensitive() {
+ let details = SignedKeyDetails::new(vec![], vec![], vec![], vec![]);
+ let key_id = key_id_from_hex("11ABcdEF33445566");
+
+ assert!(key_matches(&key_id, &details, "11abcdef33445566"));
+ assert!(key_matches(&key_id, &details, "11ABCDEF33445566"));
+ assert!(!key_matches(&key_id, &details, "11abcdef3344556")); // 15 chars
+ assert!(!key_matches(&key_id, &details, "abcdef33445566"));
+ }
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/mod.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/mod.rs
new file mode 100644
index 0000000000..d2c83be438
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/mod.rs
@@ -0,0 +1,3 @@
+pub(crate) mod key_lookup;
+pub(crate) mod private_key_service;
+pub(crate) mod public_key_service;
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service.rs
new file mode 100644
index 0000000000..bef5342362
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service.rs
@@ -0,0 +1,94 @@
+mod controller_service_definition;
+mod properties;
+
+#[cfg(test)]
+use crate::controller_services::key_lookup::key_matches;
+use crate::controller_services::private_key_service::properties::KEY_PASSPHRASE;
+use crate::utils;
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{EnableControllerService, GetProperty, Logger, MinifiError, warn};
+use pgp::composed::{Deserializable, SignedSecretKey, TheRing};
+#[cfg(test)]
+use pgp::types::KeyDetails;
+use std::path::PathBuf;
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct PGPPrivateKeyService {
+ private_keys: Vec,
+ passphrase: pgp::types::Password,
+}
+
+impl EnableControllerService for PGPPrivateKeyService {
+ fn enable(context: &P, logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ let mut private_keys = vec![];
+ if let Some(keyring_file_path) = context.get_property::(&properties::KEY_FILE)? {
+ if let Ok((keys, _headers)) = SignedSecretKey::from_armor_file_many(&keyring_file_path)
+ {
+ collect_keys(keys, &mut private_keys, logger);
+ } else if let Ok(keys) = SignedSecretKey::from_file_many(keyring_file_path) {
+ collect_keys(keys, &mut private_keys, logger);
+ }
+ }
+ if let Some(keyring_ascii) = context.get_property::(&properties::KEY)?
+ && let Ok((keys, _headers)) = SignedSecretKey::from_armor_many(keyring_ascii.as_bytes())
+ {
+ collect_keys(keys, &mut private_keys, logger);
+ }
+
+ let passphrase = context
+ .get_property::(&KEY_PASSPHRASE)?
+ .unwrap_or_default();
+
+ if private_keys.is_empty() {
+ return Err(MinifiError::controller_service_err(
+ "Could not load any valid keys",
+ ));
+ }
+ Ok(Self {
+ private_keys,
+ passphrase,
+ })
+ }
+}
+
+impl PGPPrivateKeyService {
+ pub fn get_the_ring(&'_ self) -> TheRing<'_> {
+ TheRing {
+ secret_keys: self.private_keys.iter().collect(),
+ key_passwords: vec![&self.passphrase],
+ message_password: vec![],
+ session_keys: vec![],
+ decrypt_options: Default::default(),
+ }
+ }
+
+ #[cfg(test)]
+ pub fn get_secret_key(&self, target_id: &str) -> Option<&SignedSecretKey> {
+ self.private_keys.iter().find(|private_key| {
+ key_matches(
+ &private_key.primary_key.legacy_key_id(),
+ &private_key.details,
+ target_id,
+ )
+ })
+ }
+}
+
+fn collect_keys(keys: I, out: &mut Vec, logger: &L)
+where
+ I: Iterator>,
+ L: Logger,
+{
+ for key in keys {
+ match key {
+ Ok(k) => out.push(k),
+ Err(e) => warn!(logger, "Skipping unparseable private key: {}", e),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/controller_service_definition.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/controller_service_definition.rs
new file mode 100644
index 0000000000..11f80a911d
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/controller_service_definition.rs
@@ -0,0 +1,10 @@
+use super::PGPPrivateKeyService;
+use super::properties::*;
+use minifi_native::{ControllerServiceDefinition, Property, ProvidedInterface};
+
+impl ControllerServiceDefinition for PGPPrivateKeyService {
+ const DESCRIPTION: &'static str =
+ "PGP Private Key Service provides Private Keys loaded from files or properties";
+ const PROPERTIES: &'static [Property] = &[KEY_FILE, KEY, KEY_PASSPHRASE];
+ const PROVIDED_APIS: &'static [ProvidedInterface] = &[];
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/properties.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/properties.rs
new file mode 100644
index 0000000000..6cabc0086b
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/properties.rs
@@ -0,0 +1,32 @@
+use minifi_native::Property;
+use minifi_native::PropertyConstraints::NoConstraints;
+
+pub(crate) const KEY_FILE: Property = Property {
+ name: "Key File",
+ description: "File path to PGP Secret Key encoded in binary or ASCII Armor",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: true,
+ default_value: None,
+ constraints: NoConstraints,
+};
+
+pub(crate) const KEY: Property = Property {
+ name: "Key",
+ description: "Secret Key encoded in ASCII Armor",
+ is_required: false,
+ is_sensitive: true,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: NoConstraints,
+};
+
+pub(crate) const KEY_PASSPHRASE: Property = Property {
+ name: "Key Passphrase",
+ description: "Passphrase used for decrypting Private Keys",
+ is_required: false,
+ is_sensitive: true,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: NoConstraints,
+};
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/tests.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/tests.rs
new file mode 100644
index 0000000000..efdb6e39af
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/private_key_service/tests.rs
@@ -0,0 +1,214 @@
+use super::PGPPrivateKeyService;
+use crate::test_utils::get_test_key_path;
+use minifi_native::MinifiError::ControllerServiceError;
+use minifi_native::{
+ ComponentIdentifier, EnableControllerService, MockControllerServiceContext, MockLogger,
+};
+
+fn assert_private_key_service_enable_fails_with_no_valid_keys(
+ context: &MockControllerServiceContext,
+) {
+ if let Err(ControllerServiceError(error)) =
+ PGPPrivateKeyService::enable(context, &MockLogger::new())
+ {
+ assert_eq!(error, "Could not load any valid keys");
+ } else {
+ panic!("Didnt fail with no_valid_keys");
+ }
+}
+
+#[test]
+fn test_component_id() {
+ assert_eq!(
+ PGPPrivateKeyService::CLASS_NAME,
+ "minifi_pgp::controller_services::private_key_service::PGPPrivateKeyService"
+ );
+ assert_eq!(PGPPrivateKeyService::GROUP_NAME, "minifi_pgp");
+ assert_eq!(PGPPrivateKeyService::VERSION, "0.1.0");
+}
+
+#[test]
+fn default_fails() {
+ let context = MockControllerServiceContext::new();
+ assert_private_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn corrupted_binary_keyring_file() {
+ let mut context = MockControllerServiceContext::new();
+ context
+ .properties
+ .insert("Key File".to_string(), get_test_key_path("garbage.gpg"));
+
+ assert_private_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn armored_public_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("private_mistake.asc"),
+ );
+
+ assert_private_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn corrupted_armored_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("truncated_private.asc"),
+ );
+
+ assert_private_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn non_existent_keyfile() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("non_existent.asc"),
+ );
+
+ assert_private_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn single_armored_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("alice_private.asc"),
+ );
+
+ let controller_service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(controller_service.get_secret_key("Alice").is_some());
+ assert!(
+ controller_service
+ .get_secret_key("alice@example.com")
+ .is_some()
+ );
+
+ assert!(controller_service.get_secret_key("Bob").is_none());
+ assert!(controller_service.get_secret_key("Carol").is_none());
+}
+
+#[test]
+fn single_binary_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("alice_private.gpg"),
+ );
+
+ let controller_service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(controller_service.get_secret_key("A").is_some());
+ assert!(controller_service.get_secret_key("Alice").is_some());
+ assert!(
+ controller_service
+ .get_secret_key("Alice ")
+ .is_some()
+ );
+
+ assert!(controller_service.get_secret_key("").is_none());
+
+ assert!(controller_service.get_secret_key("Bob").is_none());
+ assert!(controller_service.get_secret_key("Carol").is_none());
+}
+
+#[test]
+fn armored_keyring_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("secret_keyring.asc"),
+ );
+
+ let controller_service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(controller_service.get_secret_key("Alice").is_some());
+ assert!(controller_service.get_secret_key("Bob").is_some());
+ assert!(controller_service.get_secret_key("bob@home.io").is_some());
+ assert!(controller_service.get_secret_key("bob@work.com").is_some());
+ assert!(controller_service.get_secret_key("Carol").is_none());
+}
+
+#[test]
+fn binary_keyring_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Key File".to_string(),
+ get_test_key_path("secret_keyring.gpg"),
+ );
+
+ let controller_service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(controller_service.get_secret_key("Alice").is_some());
+ assert!(controller_service.get_secret_key("Bob").is_some());
+ assert!(controller_service.get_secret_key("bob@home.io").is_some());
+ assert!(controller_service.get_secret_key("bob@work.com").is_some());
+ assert!(controller_service.get_secret_key("Carol").is_none());
+}
+
+#[test]
+fn armored_keyring() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content = std::fs::read_to_string(get_test_key_path("secret_keyring.asc"))
+ .expect("required for test");
+
+ context.properties.insert("Key".to_string(), file_content);
+
+ let controller_service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(controller_service.get_secret_key("Alice").is_some());
+ assert!(controller_service.get_secret_key("Bob").is_some());
+ assert!(controller_service.get_secret_key("bob@home.io").is_some());
+ assert!(controller_service.get_secret_key("bob@work.com").is_some());
+ assert!(controller_service.get_secret_key("Carol").is_none());
+}
+
+#[test]
+fn armored_single_key() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content =
+ std::fs::read_to_string(get_test_key_path("alice_private.asc")).expect("required for test");
+
+ context.properties.insert("Key".to_string(), file_content);
+
+ let controller_service =
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable");
+ assert!(controller_service.get_secret_key("Alice").is_some());
+ assert!(controller_service.get_secret_key("Bob").is_none());
+ assert!(controller_service.get_secret_key("Carol").is_none());
+}
+
+#[test]
+fn corrupted_armored_key() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content = std::fs::read_to_string(get_test_key_path("truncated_private.asc"))
+ .expect("required for test");
+
+ context.properties.insert("Key".to_string(), file_content);
+
+ assert_private_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn public_ascii_key() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content =
+ std::fs::read_to_string(get_test_key_path("alice.asc")).expect("required for test");
+
+ context.properties.insert("Key".to_string(), file_content);
+
+ assert_private_key_service_enable_fails_with_no_valid_keys(&context);
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service.rs
new file mode 100644
index 0000000000..93f03d20e9
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service.rs
@@ -0,0 +1,72 @@
+mod controller_service_definition;
+mod properties;
+
+use crate::controller_services::key_lookup::key_matches;
+use crate::controller_services::public_key_service::properties::{KEYRING, KEYRING_FILE};
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{EnableControllerService, GetProperty, Logger, MinifiError, warn};
+use pgp::composed::{Deserializable, SignedPublicKey};
+use pgp::types::KeyDetails;
+use std::path::PathBuf;
+
+#[derive(Debug, ComponentIdentifier, PartialEq)]
+pub(crate) struct PGPPublicKeyService {
+ public_keys: Vec,
+}
+
+impl EnableControllerService for PGPPublicKeyService {
+ fn enable(context: &P, logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ let mut public_keys = vec![];
+ if let Some(keyring_file_path) = context.get_property::(&KEYRING_FILE)? {
+ if let Ok((keys, _headers)) = SignedPublicKey::from_armor_file_many(&keyring_file_path)
+ {
+ collect_keys(keys, &mut public_keys, logger);
+ } else if let Ok(keys) = SignedPublicKey::from_file_many(keyring_file_path) {
+ collect_keys(keys, &mut public_keys, logger);
+ }
+ }
+ if let Some(keyring_ascii) = context.get_property::(&KEYRING)?
+ && let Ok((keys, _headers)) = SignedPublicKey::from_armor_many(keyring_ascii.as_bytes())
+ {
+ collect_keys(keys, &mut public_keys, logger);
+ }
+
+ if public_keys.is_empty() {
+ return Err(MinifiError::controller_service_err(
+ "Could not load any valid keys",
+ ));
+ }
+ Ok(Self { public_keys })
+ }
+}
+
+fn collect_keys(keys: I, out: &mut Vec, logger: &L)
+where
+ I: Iterator>,
+ L: Logger,
+{
+ for key in keys {
+ match key {
+ Ok(k) => out.push(k),
+ Err(e) => warn!(logger, "Skipping unparseable public key: {}", e),
+ }
+ }
+}
+
+impl PGPPublicKeyService {
+ pub fn get(&self, target_id: &str) -> Option<&SignedPublicKey> {
+ self.public_keys.iter().find(|public_key| {
+ key_matches(
+ &public_key.primary_key.legacy_key_id(),
+ &public_key.details,
+ target_id,
+ )
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service/controller_service_definition.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service/controller_service_definition.rs
new file mode 100644
index 0000000000..77fb1c34a2
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service/controller_service_definition.rs
@@ -0,0 +1,10 @@
+use super::PGPPublicKeyService;
+use super::properties::*;
+use minifi_native::{ControllerServiceDefinition, Property, ProvidedInterface};
+
+impl ControllerServiceDefinition for PGPPublicKeyService {
+ const DESCRIPTION: &'static str =
+ "PGP Public Key Service providing Public Keys loaded from files";
+ const PROPERTIES: &'static [Property] = &[KEYRING_FILE, KEYRING];
+ const PROVIDED_APIS: &'static [ProvidedInterface] = &[];
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service/properties.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service/properties.rs
new file mode 100644
index 0000000000..2087163ea6
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service/properties.rs
@@ -0,0 +1,22 @@
+use minifi_native::Property;
+use minifi_native::PropertyConstraints::NoConstraints;
+
+pub(crate) const KEYRING_FILE: Property = Property {
+ name: "Keyring File",
+ description: "File path to PGP Keyring or Secret Key encoded in binary or ASCII Armor",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: true,
+ default_value: None,
+ constraints: NoConstraints,
+};
+
+pub(crate) const KEYRING: Property = Property {
+ name: "Keyring",
+ description: "PGP Keyring or Secret Key encoded in ASCII Armor",
+ is_required: false,
+ is_sensitive: true,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: NoConstraints,
+};
diff --git a/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service/tests.rs b/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service/tests.rs
new file mode 100644
index 0000000000..4adbb14b4e
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/controller_services/public_key_service/tests.rs
@@ -0,0 +1,245 @@
+use super::PGPPublicKeyService;
+use crate::test_utils::get_test_key_path;
+use minifi_native::MinifiError::ControllerServiceError;
+use minifi_native::{
+ ComponentIdentifier, EnableControllerService, MockControllerServiceContext, MockLogger,
+};
+use pgp::types::KeyDetails;
+
+fn assert_public_key_service_enable_fails_with_no_valid_keys(
+ context: &MockControllerServiceContext,
+) {
+ if let Err(ControllerServiceError(error)) =
+ PGPPublicKeyService::enable(context, &MockLogger::new())
+ {
+ assert_eq!(error, "Could not load any valid keys");
+ } else {
+ panic!("Didnt fail with no_valid_keys");
+ }
+}
+
+#[test]
+fn test_component_id() {
+ assert_eq!(
+ PGPPublicKeyService::CLASS_NAME,
+ "minifi_pgp::controller_services::public_key_service::PGPPublicKeyService"
+ );
+ assert_eq!(PGPPublicKeyService::GROUP_NAME, "minifi_pgp");
+ assert_eq!(PGPPublicKeyService::VERSION, "0.1.0");
+}
+
+#[test]
+fn default_fails() {
+ let context = MockControllerServiceContext::new();
+
+ assert_public_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn corrupted_binary_keyring_file() {
+ let mut context = MockControllerServiceContext::new();
+ context
+ .properties
+ .insert("Keyring File".to_string(), get_test_key_path("garbage.gpg"));
+
+ assert_public_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn armored_private_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Keyring File".to_string(),
+ get_test_key_path("alice_private.asc"),
+ );
+
+ assert_public_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn corrupted_armored_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Keyring File".to_string(),
+ get_test_key_path("truncated.asc"),
+ );
+
+ assert_public_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn non_existent_keyfile() {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Keyring File".to_string(),
+ get_test_key_path("non_existent.asc"),
+ );
+
+ assert_public_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn single_armored_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context
+ .properties
+ .insert("Keyring File".to_string(), get_test_key_path("alice.asc"));
+
+ let controller_service =
+ PGPPublicKeyService::enable(&context, &MockLogger::new()).expect("enable should succeed");
+
+ assert!(controller_service.get("Alice").is_some());
+ assert!(controller_service.get("alice@example.com").is_some());
+
+ assert!(controller_service.get("Bob").is_none());
+ assert!(controller_service.get("Carol").is_none());
+}
+
+#[test]
+fn single_binary_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context
+ .properties
+ .insert("Keyring File".to_string(), get_test_key_path("alice.gpg"));
+
+ let controller_service =
+ PGPPublicKeyService::enable(&context, &MockLogger::new()).expect("enable should succeed");
+ assert!(controller_service.get("A").is_some());
+ assert!(controller_service.get("Alice").is_some());
+ assert!(
+ controller_service
+ .get("Alice ")
+ .is_some()
+ );
+
+ assert!(controller_service.get("").is_none());
+
+ assert!(controller_service.get("Bob").is_none());
+ assert!(controller_service.get("Carol").is_none());
+}
+
+#[test]
+fn armored_keyring_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context
+ .properties
+ .insert("Keyring File".to_string(), get_test_key_path("keyring.asc"));
+
+ let controller_service =
+ PGPPublicKeyService::enable(&context, &MockLogger::new()).expect("enable should succeed");
+ assert!(controller_service.get("Alice").is_some());
+ assert!(controller_service.get("Bob").is_some());
+ assert!(controller_service.get("bob@home.io").is_some());
+ assert!(controller_service.get("bob@work.com").is_some());
+ assert!(controller_service.get("Carol").is_none());
+}
+
+#[test]
+fn binary_keyring_key_file() {
+ let mut context = MockControllerServiceContext::new();
+ context
+ .properties
+ .insert("Keyring File".to_string(), get_test_key_path("keyring.gpg"));
+
+ let controller_service =
+ PGPPublicKeyService::enable(&context, &MockLogger::new()).expect("enable should succeed");
+ assert!(controller_service.get("Alice").is_some());
+ assert!(controller_service.get("Bob").is_some());
+ assert!(controller_service.get("bob@home.io").is_some());
+ assert!(controller_service.get("bob@work.com").is_some());
+ assert!(controller_service.get("Carol").is_none());
+}
+
+#[test]
+fn armored_keyring() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content =
+ std::fs::read_to_string(get_test_key_path("keyring.asc")).expect("required for test");
+
+ context
+ .properties
+ .insert("Keyring".to_string(), file_content);
+
+ let controller_service =
+ PGPPublicKeyService::enable(&context, &MockLogger::new()).expect("enable should succeed");
+ assert!(controller_service.get("Alice").is_some());
+ assert!(controller_service.get("Bob").is_some());
+ assert!(controller_service.get("bob@home.io").is_some());
+ assert!(controller_service.get("bob@work.com").is_some());
+ assert!(controller_service.get("Carol").is_none());
+}
+
+#[test]
+fn armored_single_key() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content =
+ std::fs::read_to_string(get_test_key_path("alice.asc")).expect("required for test");
+
+ context
+ .properties
+ .insert("Keyring".to_string(), file_content);
+
+ let controller_service =
+ PGPPublicKeyService::enable(&context, &MockLogger::new()).expect("enable should succeed");
+ assert!(controller_service.get("Alice").is_some());
+ assert!(controller_service.get("Bob").is_none());
+ assert!(controller_service.get("Carol").is_none());
+}
+
+#[test]
+fn corrupted_armored_key() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content =
+ std::fs::read_to_string(get_test_key_path("truncated.asc")).expect("required for test");
+
+ context
+ .properties
+ .insert("Keyring".to_string(), file_content);
+
+ assert_public_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn private_ascii_key() {
+ let mut context = MockControllerServiceContext::new();
+
+ let file_content =
+ std::fs::read_to_string(get_test_key_path("alice_private.asc")).expect("required for test");
+
+ context
+ .properties
+ .insert("Keyring".to_string(), file_content);
+
+ assert_public_key_service_enable_fails_with_no_valid_keys(&context);
+}
+
+#[test]
+fn looks_up_by_key_id_hex() {
+ let mut context = MockControllerServiceContext::new();
+ context
+ .properties
+ .insert("Keyring File".to_string(), get_test_key_path("alice.asc"));
+
+ let controller_service =
+ PGPPublicKeyService::enable(&context, &MockLogger::new()).expect("enable should succeed");
+
+ // Get Alice's Key ID from the loaded key so the test doesn't hard-code hex bytes.
+ let alice = controller_service.get("Alice").expect("Alice should exist");
+ let key_id_hex = alice.primary_key.legacy_key_id().to_string();
+ assert_eq!(key_id_hex.len(), 16);
+
+ // Full 16-char hex, both cases, should match.
+ assert!(controller_service.get(&key_id_hex).is_some());
+ assert!(
+ controller_service
+ .get(&key_id_hex.to_ascii_uppercase())
+ .is_some()
+ );
+
+ // A partial or unrelated hex string should not.
+ assert!(controller_service.get(&key_id_hex[..8]).is_none());
+ assert!(controller_service.get("0123456789abcdef").is_none());
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/lib.rs b/minifi_rust/extensions/minifi_pgp/src/lib.rs
new file mode 100644
index 0000000000..6c01256874
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/lib.rs
@@ -0,0 +1,23 @@
+mod controller_services;
+mod processors;
+
+use crate::controller_services::private_key_service::PGPPrivateKeyService;
+use crate::controller_services::public_key_service::PGPPublicKeyService;
+use crate::processors::decrypt_content::DecryptContentPGP;
+use crate::processors::encrypt_content::EncryptContentPGP;
+use minifi_native::{FlowFileStreamTransformProcessorType, MultiThreaded};
+
+minifi_native::declare_minifi_extension!(
+ processors: [
+ (FlowFileStreamTransformProcessorType, MultiThreaded, EncryptContentPGP),
+ (FlowFileStreamTransformProcessorType, MultiThreaded, DecryptContentPGP),
+ ],
+ controllers: [
+ PGPPublicKeyService,
+ PGPPrivateKeyService,
+ ]
+);
+
+#[cfg(test)]
+mod test_utils;
+mod utils;
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content.rs b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content.rs
new file mode 100644
index 0000000000..e97b15e54a
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content.rs
@@ -0,0 +1,150 @@
+mod output_attributes;
+pub(crate) mod properties;
+mod relationships;
+
+use crate::controller_services::private_key_service::PGPPrivateKeyService;
+use crate::processors::decrypt_content::properties::{
+ DECRYPTION_STRATEGY, PRIVATE_KEY_SERVICE, SYMMETRIC_PASSWORD,
+};
+use crate::processors::decrypt_content::relationships::{FAILURE, SUCCESS};
+use crate::utils;
+use minifi_native::macros::{ComponentIdentifier, PropertyType};
+use minifi_native::{
+ FlowFileStreamTransform, GetControllerService, GetProperty, InputStream, Logger, MinifiError,
+ OutputStream, Schedule, TransformStreamResult, warn,
+};
+use pgp::composed::{Message, TheRing};
+use std::collections::HashMap;
+use std::fmt::Debug;
+use strum_macros::{Display, EnumString, IntoStaticStr, VariantNames};
+
+#[derive(
+ Debug, Clone, Copy, PartialEq, Display, EnumString, VariantNames, IntoStaticStr, PropertyType,
+)]
+#[strum(serialize_all = "UPPERCASE", const_into_str)]
+enum DecryptionStrategy {
+ Decrypted,
+ Packaged,
+}
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct DecryptContentPGP {
+ decompress_data: bool,
+ symmetric_password: Option,
+}
+
+impl Schedule for DecryptContentPGP {
+ fn schedule(context: &P, _logger: &L) -> Result
+ where
+ Self: Sized,
+ L: Logger,
+ {
+ let decryption_strategy =
+ context.get_req_property::(&DECRYPTION_STRATEGY)?;
+
+ let symmetric_password = context.get_property::(&SYMMETRIC_PASSWORD)?;
+ let has_context_service = context
+ .get_property::(&PRIVATE_KEY_SERVICE)?
+ .is_some();
+ if !has_context_service && symmetric_password.is_none() {
+ Err(MinifiError::schedule_err(
+ "Either Symmetric Password or Private Key Service must be set",
+ ))
+ } else {
+ Ok(DecryptContentPGP {
+ decompress_data: decryption_strategy == DecryptionStrategy::Decrypted,
+ symmetric_password,
+ })
+ }
+ }
+}
+
+impl DecryptContentPGP {
+ fn decrypt_msg<'a>(
+ &'a self,
+ msg: Message<'a>,
+ private_key_service: Option<&'a PGPPrivateKeyService>,
+ ) -> pgp::errors::Result> {
+ let mut ring = if let Some(pks) = private_key_service {
+ pks.get_the_ring()
+ } else {
+ TheRing::default()
+ };
+
+ ring.decrypt_options = ring.decrypt_options.enable_gnupg_aead();
+
+ if let Some(sym_passwd) = &self.symmetric_password {
+ ring.message_password.push(sym_passwd);
+ }
+ let (decrypted_msg, _ring_result) = msg.decrypt_the_ring(ring, false)?;
+ Ok(decrypted_msg)
+ }
+
+ fn extract_attributes_from_decrypted_message(
+ decrypted_msg: &Message,
+ ) -> HashMap {
+ let mut attributes_to_add = HashMap::new();
+ if let Some(literal_data_header) = decrypted_msg.literal_data_header() {
+ if let Ok(file_name) = str::from_utf8(literal_data_header.file_name()) {
+ attributes_to_add.insert(
+ output_attributes::LITERAL_DATA_FILENAME.name.to_string(),
+ file_name.to_string(),
+ );
+ }
+ attributes_to_add.insert(
+ output_attributes::LITERAL_DATA_MODIFIED.name.to_string(),
+ (1000u64 * literal_data_header.created().as_secs() as u64).to_string(), // Nifi uses ms timestamp
+ );
+ }
+ attributes_to_add
+ }
+}
+
+impl FlowFileStreamTransform for DecryptContentPGP {
+ fn transform(
+ &self,
+ context: &Ctx,
+ input_stream: &mut dyn InputStream,
+ output_stream: &mut dyn OutputStream,
+ logger: &LoggerImpl,
+ ) -> Result {
+ let Ok(msg) = Message::from_reader(input_stream).map(|(msg, _header)| msg) else {
+ warn!(logger, "No valid PGP message found");
+ return Ok(TransformStreamResult::route_without_changes(&FAILURE));
+ };
+
+ let private_key_service =
+ context.get_controller_service::(&PRIVATE_KEY_SERVICE)?;
+
+ let Ok(mut decrypted_msg) = self.decrypt_msg(msg, private_key_service) else {
+ warn!(logger, "Failed to decrypt data");
+ return Ok(TransformStreamResult::route_without_changes(&FAILURE));
+ };
+
+ if self.decompress_data && decrypted_msg.is_compressed() {
+ match decrypted_msg.decompress() {
+ Ok(decompressed_data) => {
+ decrypted_msg = decompressed_data;
+ }
+ Err(e) => {
+ warn!(logger, "Failed to decompress data: {}", e);
+ return Ok(TransformStreamResult::route_without_changes(&FAILURE));
+ }
+ }
+ };
+
+ let attributes_to_add = Self::extract_attributes_from_decrypted_message(&decrypted_msg);
+ let Ok(_written_bytes) = std::io::copy(&mut decrypted_msg.into_inner(), output_stream)
+ else {
+ warn!(logger, "Failed to extract raw data from decrypted message");
+ return Ok(TransformStreamResult::route_without_changes(&FAILURE));
+ };
+
+ Ok(TransformStreamResult::new(&SUCCESS, attributes_to_add))
+ }
+}
+
+#[cfg(test)]
+mod tests;
+
+pub(crate) mod processor_definition;
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/output_attributes.rs b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/output_attributes.rs
new file mode 100644
index 0000000000..e4a894a1cc
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/output_attributes.rs
@@ -0,0 +1,13 @@
+use minifi_native::OutputAttribute;
+
+pub(crate) const LITERAL_DATA_FILENAME: OutputAttribute = OutputAttribute {
+ name: "pgp.literal.data.filename",
+ relationships: &["success"],
+ description: "Filename from decrypted Literal Data (Note that OpenPGP signatures do not include the formatting octet, the file name, and the date field of the Literal Data packet in a signature hash; therefore, those fields are not protected against tampering in a signed document. Therefore a lot of implementations omit these inherently malleable metadata)",
+};
+
+pub(crate) const LITERAL_DATA_MODIFIED: OutputAttribute = OutputAttribute {
+ name: "pgp.literal.data.modified",
+ relationships: &["success"],
+ description: "Modified Date from decrypted Literal Data (Note that OpenPGP signatures do not include the formatting octet, the file name, and the date field of the Literal Data packet in a signature hash; therefore, those fields are not protected against tampering in a signed document. Therefore a lot of implementations omit these inherently malleable metadata)",
+};
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/processor_definition.rs b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/processor_definition.rs
new file mode 100644
index 0000000000..c9c6fddf61
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/processor_definition.rs
@@ -0,0 +1,22 @@
+use super::{DecryptContentPGP, output_attributes, properties, relationships};
+use minifi_native::{
+ OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, Property, Relationship,
+};
+
+impl ProcessorDefinition for DecryptContentPGP {
+ const DESCRIPTION: &'static str = "Decrypt contents of OpenPGP messages. Using the Packaged Decryption Strategy preserves OpenPGP encoding to support subsequent signature verification.";
+ const INPUT_REQUIREMENT: ProcessorInputRequirement = ProcessorInputRequirement::Required;
+ const SUPPORTS_DYNAMIC_PROPERTIES: bool = false;
+ const SUPPORTS_DYNAMIC_RELATIONSHIPS: bool = false;
+ const OUTPUT_ATTRIBUTES: &'static [OutputAttribute] = &[
+ output_attributes::LITERAL_DATA_FILENAME,
+ output_attributes::LITERAL_DATA_MODIFIED,
+ ];
+ const RELATIONSHIPS: &'static [Relationship] =
+ &[relationships::SUCCESS, relationships::FAILURE];
+ const PROPERTIES: &'static [Property] = &[
+ properties::DECRYPTION_STRATEGY,
+ properties::SYMMETRIC_PASSWORD,
+ properties::PRIVATE_KEY_SERVICE,
+ ];
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/properties.rs b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/properties.rs
new file mode 100644
index 0000000000..d642e7f35d
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/properties.rs
@@ -0,0 +1,36 @@
+use crate::controller_services::private_key_service::PGPPrivateKeyService;
+use crate::processors::decrypt_content::DecryptionStrategy;
+use minifi_native::ComponentIdentifier;
+use minifi_native::Property;
+use minifi_native::PropertyConstraints::{AllowedType, AllowedValues, NoConstraints};
+use strum::VariantNames;
+
+pub(crate) const DECRYPTION_STRATEGY: Property = Property {
+ name: "Decryption Strategy",
+ description: "Strategy for writing files to success after decryption",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some(DecryptionStrategy::Decrypted.into_str()),
+ constraints: AllowedValues(DecryptionStrategy::VARIANTS),
+};
+
+pub(crate) const SYMMETRIC_PASSWORD: Property = Property {
+ name: "Symmetric Password",
+ description: "Password used for decrypting data encrypted with Password-Based Encryption",
+ is_required: false,
+ is_sensitive: true,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: NoConstraints,
+};
+
+pub(crate) const PRIVATE_KEY_SERVICE: Property = Property {
+ name: "Private Key Service",
+ description: "PGP Private Key Service for decrypting data encrypted with Public Key Encryption",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: AllowedType(PGPPrivateKeyService::CLASS_NAME),
+};
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/relationships.rs b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/relationships.rs
new file mode 100644
index 0000000000..a4403da17d
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/relationships.rs
@@ -0,0 +1,11 @@
+use minifi_native::Relationship;
+
+pub(crate) const SUCCESS: Relationship = Relationship {
+ name: "success",
+ description: "Decryption Succeeded",
+};
+
+pub(crate) const FAILURE: Relationship = Relationship {
+ name: "failure",
+ description: "Decryption Failed",
+};
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/tests.rs b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/tests.rs
new file mode 100644
index 0000000000..65d4167dcb
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/decrypt_content/tests.rs
@@ -0,0 +1,253 @@
+use crate::controller_services::private_key_service::PGPPrivateKeyService;
+use crate::processors::decrypt_content::{DecryptContentPGP, output_attributes};
+use crate::test_utils;
+use crate::test_utils::get_test_message;
+use minifi_native::{
+ ComponentIdentifier, EnableControllerService, FlowFileStreamTransform, IoState,
+ MockControllerServiceContext, MockLogger, MockProcessContext, Schedule,
+};
+
+#[test]
+fn test_ids() {
+ assert_eq!(
+ DecryptContentPGP::CLASS_NAME,
+ "minifi_pgp::processors::decrypt_content::DecryptContentPGP"
+ );
+ assert_eq!(DecryptContentPGP::GROUP_NAME, "minifi_pgp");
+ assert_eq!(DecryptContentPGP::VERSION, "0.1.0");
+}
+
+#[test]
+fn fails_to_schedule_by_default() {
+ let decrypt_content =
+ DecryptContentPGP::schedule(&MockProcessContext::new(), &MockLogger::new());
+ assert!(decrypt_content.is_err());
+}
+
+#[test]
+fn schedules_with_password() {
+ let mut context = MockProcessContext::new();
+ context.properties.insert(
+ super::properties::SYMMETRIC_PASSWORD.name.to_string(),
+ "my_secret_password".to_string(),
+ );
+ let decrypt_content = DecryptContentPGP::schedule(&context, &MockLogger::new());
+ assert!(decrypt_content.is_ok());
+}
+
+#[test]
+fn schedules_with_controller() {
+ let mut context = MockProcessContext::new();
+ context.properties.insert(
+ super::properties::PRIVATE_KEY_SERVICE.name.to_string(),
+ "my_private_key_service".to_string(),
+ );
+ let decrypt_content = DecryptContentPGP::schedule(&context, &MockLogger::new());
+ assert!(decrypt_content.is_ok());
+}
+
+#[test]
+fn schedule_rejects_invalid_strategy_without_panicking() {
+ let mut context = MockProcessContext::new();
+ context.properties.insert(
+ super::properties::DECRYPTION_STRATEGY.name.to_string(),
+ "NOT_A_STRATEGY".to_string(),
+ );
+ context.properties.insert(
+ super::properties::SYMMETRIC_PASSWORD.name.to_string(),
+ "my_secret_password".to_string(),
+ );
+ // Must return Err, not panic.
+ let result = DecryptContentPGP::schedule(&context, &MockLogger::new());
+ assert!(result.is_err(), "expected schedule to fail on bad strategy");
+}
+
+#[derive(Copy, Clone)]
+struct PrivateKeyData {
+ key_filename: &'static str,
+ passphrase: Option<&'static str>,
+}
+
+impl PrivateKeyData {
+ fn into_controller(self) -> PGPPrivateKeyService {
+ let mut context = MockControllerServiceContext::new();
+ context
+ .properties
+ .insert("Key File", test_utils::get_test_key_path(self.key_filename));
+
+ if let Some(passphrase) = self.passphrase {
+ context.properties.insert("Key Passphrase", passphrase);
+ }
+
+ PGPPrivateKeyService::enable(&context, &MockLogger::new()).expect("should enable")
+ }
+}
+
+fn test_decryption(
+ message_file_name: &str,
+ private_key_data: Option,
+ symmetric_password: Option<&'static str>,
+ expected_result: Result<&[u8], ()>,
+) {
+ let mut processor_context = MockProcessContext::new();
+ if let Some(private_key) = private_key_data {
+ processor_context.controller_services.insert(
+ "my_private_key_service".to_string(),
+ Box::new(private_key.into_controller()),
+ );
+ processor_context.properties.insert(
+ super::properties::PRIVATE_KEY_SERVICE.name.to_string(),
+ "my_private_key_service".to_string(),
+ );
+ }
+ if let Some(symmetric_password) = symmetric_password {
+ processor_context.properties.insert(
+ super::properties::SYMMETRIC_PASSWORD.name.to_string(),
+ symmetric_password.to_string(),
+ );
+ }
+
+ let decrypt_content = DecryptContentPGP::schedule(&processor_context, &MockLogger::new())
+ .expect("Should schedule with the configured properties");
+ let mut output: Vec = Vec::new();
+ let mut flow_file_stream = std::io::Cursor::new(get_test_message(message_file_name));
+ let res = decrypt_content
+ .transform(
+ &processor_context,
+ &mut flow_file_stream,
+ &mut output,
+ &MockLogger::new(),
+ )
+ .expect("Should be able to transform");
+
+ match expected_result {
+ Ok(_result_bytes) => {
+ assert_eq!(
+ res.target_relationship_name(),
+ super::relationships::SUCCESS.name
+ );
+ assert_eq!(res.write_status(), IoState::Ok);
+ let data_modified = res
+ .get_attribute(output_attributes::LITERAL_DATA_MODIFIED.name)
+ .unwrap()
+ .parse::()
+ .expect("Should be u64");
+ assert!(data_modified > 1770000000000);
+ assert!(data_modified < 1780000000000);
+ assert!(
+ res.get_attribute(output_attributes::LITERAL_DATA_FILENAME.name)
+ .is_some()
+ );
+ }
+ Err(_) => {
+ assert_eq!(
+ res.target_relationship_name(),
+ super::relationships::FAILURE.name
+ );
+ assert_eq!(res.write_status(), IoState::Cancel);
+ }
+ }
+}
+
+#[test]
+fn decrypts_with_password() {
+ test_decryption(
+ "password_encrypted_foo.gpg",
+ None,
+ Some("my_secret_password"),
+ Ok("foo\n".as_bytes()),
+ );
+ test_decryption(
+ "password_encrypted_foo.asc",
+ None,
+ Some("my_secret_password"),
+ Ok("foo\n".as_bytes()),
+ );
+ test_decryption(
+ "foo_for_alice.gpg",
+ None,
+ Some("my_secret_password"),
+ Err(()),
+ );
+ test_decryption(
+ "foo_for_alice.asc",
+ None,
+ Some("my_secret_password"),
+ Err(()),
+ );
+}
+
+#[test]
+fn decrypts_for_alice() {
+ let alice_private_key_data = PrivateKeyData {
+ key_filename: "alice_private.asc",
+ passphrase: Some("whiterabbit"),
+ };
+
+ test_decryption(
+ "foo_for_alice.asc",
+ Some(alice_private_key_data),
+ None,
+ Ok("foo\n".as_bytes()),
+ );
+
+ test_decryption(
+ "foo_for_alice.gpg",
+ Some(alice_private_key_data),
+ None,
+ Ok("foo\n".as_bytes()),
+ );
+
+ test_decryption(
+ "password_encrypted_foo.gpg",
+ Some(alice_private_key_data),
+ None,
+ Err(()),
+ );
+
+ test_decryption(
+ "password_encrypted_foo.asc",
+ Some(alice_private_key_data),
+ None,
+ Err(()),
+ );
+}
+
+#[test]
+fn decryption_of_not_encrypted_data() {
+ let alice_private_key = PrivateKeyData {
+ key_filename: "alice_private.asc",
+ passphrase: Some("whiterabbit"),
+ };
+
+ let mut processor_context = MockProcessContext::new();
+ processor_context.controller_services.insert(
+ "my_private_key_service".to_string(),
+ Box::new(alice_private_key.into_controller()),
+ );
+ processor_context.properties.insert(
+ super::properties::PRIVATE_KEY_SERVICE.name.to_string(),
+ "my_private_key_service".to_string(),
+ );
+
+ let logger = MockLogger::new();
+
+ let decrypt_content = DecryptContentPGP::schedule(&processor_context, &logger)
+ .expect("Should schedule without any properties");
+ let mut result: Vec = vec![];
+ let mut flow_file_stream = std::io::Cursor::new("something not encrypted".as_bytes());
+ let res = decrypt_content
+ .transform(
+ &processor_context,
+ &mut flow_file_stream,
+ &mut result,
+ &logger,
+ )
+ .expect("Should be able to transform");
+
+ assert_eq!(
+ res.target_relationship_name(),
+ super::relationships::FAILURE.name
+ );
+ assert_eq!(res.write_status(), IoState::Cancel);
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content.rs b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content.rs
new file mode 100644
index 0000000000..1c1d47d7dc
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content.rs
@@ -0,0 +1,152 @@
+use minifi_native::{
+ FlowFileStreamTransform, GetAttribute, GetControllerService, GetProperty, InputStream, Logger,
+ MinifiError, OutputStream, Schedule, TransformStreamResult, warn,
+};
+use pgp::composed::{ArmorOptions, MessageBuilder, SignedPublicKey};
+use pgp::types::StringToKey;
+use std::collections::HashMap;
+
+mod output_attributes;
+mod properties;
+mod relationships;
+
+use crate::controller_services::public_key_service::PGPPublicKeyService;
+use crate::processors::encrypt_content::output_attributes::FILE_ENCODING;
+use crate::processors::encrypt_content::properties::{
+ PASSWORD, PUBLIC_KEY_SEARCH, PUBLIC_KEY_SERVICE,
+};
+use crate::processors::encrypt_content::relationships::{FAILURE, SUCCESS};
+use minifi_native::macros::{ComponentIdentifier, PropertyType};
+use strum_macros::{Display, EnumString, IntoStaticStr, VariantNames};
+
+#[derive(
+ Debug, Clone, Copy, PartialEq, Display, EnumString, VariantNames, IntoStaticStr, PropertyType,
+)]
+#[strum(serialize_all = "UPPERCASE", const_into_str)]
+enum FileEncoding {
+ Ascii,
+ Binary,
+}
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct EncryptContentPGP {
+ file_encoding: FileEncoding,
+}
+
+#[cfg(not(test))]
+fn string_to_key() -> StringToKey {
+ StringToKey::new_argon2(rand::thread_rng(), 3, 4, 16) // 64 MiB with rpgp's recommended parameter choice
+}
+
+#[cfg(test)]
+fn string_to_key() -> StringToKey {
+ StringToKey::new_argon2(rand::thread_rng(), 1, 1, 10) // fast for unit tests
+}
+
+impl EncryptContentPGP {
+ fn encrypt_bytes(
+ &self,
+ input_stream: &mut dyn InputStream,
+ output_stream: &mut dyn OutputStream,
+ pub_key: Option<&SignedPublicKey>,
+ passphrase: Option<&str>,
+ file_name: String,
+ ) -> pgp::errors::Result<()> {
+ let mut builder = MessageBuilder::from_reader(file_name, input_stream).seipd_v1(
+ rand::thread_rng(),
+ pgp::crypto::sym::SymmetricKeyAlgorithm::AES256,
+ );
+
+ if let Some(pub_key) = pub_key {
+ builder.encrypt_to_key(rand::thread_rng(), pub_key)?;
+ }
+
+ if let Some(passphrase) = passphrase {
+ builder.encrypt_with_password(string_to_key(), &passphrase.into())?;
+ }
+
+ match self.file_encoding {
+ FileEncoding::Ascii => builder.to_armored_writer(
+ rand::thread_rng(),
+ ArmorOptions::default(),
+ output_stream,
+ ),
+ FileEncoding::Binary => builder.to_writer(rand::thread_rng(), output_stream),
+ }
+ }
+}
+
+impl Schedule for EncryptContentPGP {
+ fn schedule(context: &P, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ let file_encoding = context.get_req_property::(&properties::FILE_ENCODING)?;
+
+ let has_password = context.get_property::(&PASSWORD)?.is_some();
+ let has_public_key = context
+ .get_property::(&PUBLIC_KEY_SERVICE)?
+ .is_some()
+ && context
+ .get_property::(&PUBLIC_KEY_SEARCH)?
+ .is_some();
+
+ if !has_password && !has_public_key {
+ Err(MinifiError::schedule_err(
+ "Either a password or Public Key Service with Public Key Search should be configured to encrypt files",
+ ))
+ } else {
+ Ok(EncryptContentPGP { file_encoding })
+ }
+ }
+}
+
+impl FlowFileStreamTransform for EncryptContentPGP {
+ fn transform(
+ &self,
+ context: &Ctx,
+ input_stream: &mut dyn InputStream,
+ output_stream: &mut dyn OutputStream,
+ logger: &LoggerImpl,
+ ) -> Result {
+ let file_name = context.get_attribute("filename")?.unwrap_or_default();
+ let public_key = if let (Some(pub_key_search), Some(public_key_service)) = (
+ context.get_property::(&PUBLIC_KEY_SEARCH)?,
+ context.get_controller_service::(&PUBLIC_KEY_SERVICE)?,
+ ) {
+ public_key_service.get(&pub_key_search)
+ } else {
+ None
+ };
+ let password = context.get_property::(&PASSWORD)?;
+ if public_key.is_none() && password.is_none() {
+ warn!(logger, "No password or public key to encrypt with");
+ return Ok(TransformStreamResult::route_without_changes(&FAILURE));
+ }
+
+ match self.encrypt_bytes(
+ input_stream,
+ output_stream,
+ public_key,
+ password.as_deref(),
+ file_name,
+ ) {
+ Ok(_) => Ok(TransformStreamResult::new(
+ &SUCCESS,
+ HashMap::from([(
+ FILE_ENCODING.name.to_string(),
+ self.file_encoding.to_string(),
+ )]),
+ )),
+ Err(e) => {
+ warn!(logger, "Failed to encrypt content {:?}", e);
+ Ok(TransformStreamResult::route_without_changes(&FAILURE))
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests;
+
+pub(crate) mod processor_definition;
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/output_attributes.rs b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/output_attributes.rs
new file mode 100644
index 0000000000..07f3396788
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/output_attributes.rs
@@ -0,0 +1,7 @@
+use minifi_native::OutputAttribute;
+
+pub(crate) const FILE_ENCODING: OutputAttribute = OutputAttribute {
+ name: "pgp.file.encoding",
+ relationships: &["success"],
+ description: "File Encoding",
+};
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/processor_definition.rs b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/processor_definition.rs
new file mode 100644
index 0000000000..4ac06faa7a
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/processor_definition.rs
@@ -0,0 +1,20 @@
+use super::{EncryptContentPGP, output_attributes, properties, relationships};
+use minifi_native::{
+ OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, Property, Relationship,
+};
+
+impl ProcessorDefinition for EncryptContentPGP {
+ const DESCRIPTION: &'static str = "Encrypt contents using OpenPGP.";
+ const INPUT_REQUIREMENT: ProcessorInputRequirement = ProcessorInputRequirement::Required;
+ const SUPPORTS_DYNAMIC_PROPERTIES: bool = false;
+ const SUPPORTS_DYNAMIC_RELATIONSHIPS: bool = false;
+ const OUTPUT_ATTRIBUTES: &'static [OutputAttribute] = &[output_attributes::FILE_ENCODING];
+ const RELATIONSHIPS: &'static [Relationship] =
+ &[relationships::SUCCESS, relationships::FAILURE];
+ const PROPERTIES: &'static [Property] = &[
+ properties::FILE_ENCODING,
+ properties::PASSWORD,
+ properties::PUBLIC_KEY_SEARCH,
+ properties::PUBLIC_KEY_SERVICE,
+ ];
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/properties.rs b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/properties.rs
new file mode 100644
index 0000000000..70ebdb404d
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/properties.rs
@@ -0,0 +1,46 @@
+use crate::controller_services::public_key_service::PGPPublicKeyService;
+use crate::processors::encrypt_content::FileEncoding;
+use minifi_native::ComponentIdentifier;
+use minifi_native::Property;
+use minifi_native::PropertyConstraints::{AllowedType, AllowedValues, NoConstraints};
+use strum::VariantNames;
+
+pub(crate) const FILE_ENCODING: Property = Property {
+ name: "File Encoding",
+ description: "File Encoding for encryption",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some(FileEncoding::Binary.into_str()),
+ constraints: AllowedValues(FileEncoding::VARIANTS),
+};
+
+pub(crate) const PASSWORD: Property = Property {
+ name: "Symmetric Password",
+ description: "Password used for encrypting data with Password-Based Encryption",
+ is_required: false,
+ is_sensitive: true,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: NoConstraints,
+};
+
+pub(crate) const PUBLIC_KEY_SEARCH: Property = Property {
+ name: "Public Key Search",
+ description: "PGP Public Key Search will be used to match against the User ID or Key ID when formatted as uppercase hexadecimal string of 16 characters",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: true,
+ default_value: None,
+ constraints: NoConstraints,
+};
+
+pub(crate) const PUBLIC_KEY_SERVICE: Property = Property {
+ name: "Public Key Service",
+ description: "PGP Public Key Service for encrypting data with Public Key Encryption",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: AllowedType(PGPPublicKeyService::CLASS_NAME),
+};
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/relationships.rs b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/relationships.rs
new file mode 100644
index 0000000000..fcdda9921c
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/relationships.rs
@@ -0,0 +1,11 @@
+use minifi_native::Relationship;
+
+pub(crate) const SUCCESS: Relationship = Relationship {
+ name: "success",
+ description: "Encryption Succeeded",
+};
+
+pub(crate) const FAILURE: Relationship = Relationship {
+ name: "failure",
+ description: "Encryption Failed",
+};
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/tests.rs b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/tests.rs
new file mode 100644
index 0000000000..2e961dd7ee
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/encrypt_content/tests.rs
@@ -0,0 +1,137 @@
+use super::*;
+use crate::test_utils;
+use minifi_native::{
+ ComponentIdentifier, EnableControllerService, IoState, MockControllerServiceContext,
+ MockLogger, MockProcessContext,
+};
+
+#[test]
+fn test_ids() {
+ assert_eq!(
+ EncryptContentPGP::CLASS_NAME,
+ "minifi_pgp::processors::encrypt_content::EncryptContentPGP"
+ );
+ assert_eq!(EncryptContentPGP::GROUP_NAME, "minifi_pgp");
+ assert_eq!(EncryptContentPGP::VERSION, "0.1.0");
+}
+
+#[test]
+fn cannot_schedule_without_password_or_public_key() {
+ assert!(EncryptContentPGP::schedule(&MockProcessContext::new(), &MockLogger::new()).is_err());
+}
+
+fn assert_content(transform_result: &TransformStreamResult, is_ascii: bool) {
+ assert_eq!(transform_result.target_relationship_name(), SUCCESS.name);
+ assert_eq!(transform_result.write_status(), IoState::Ok);
+ assert_eq!(
+ transform_result.get_attribute("pgp.file.encoding").unwrap(),
+ if is_ascii { "ASCII" } else { "BINARY" }
+ );
+}
+
+#[test]
+fn encrypts_via_passphrase() {
+ let mut context = MockProcessContext::new();
+ context.properties.insert(PASSWORD.name, "password");
+ context
+ .attributes
+ .insert("filename".to_owned(), "mammut".to_owned());
+
+ let mut result: Vec = Vec::new();
+ let mut input_stream = std::io::Cursor::new("foo".as_bytes());
+ let processor =
+ EncryptContentPGP::schedule(&context, &MockLogger::new()).expect("should schedule");
+ let transformed_ff = processor
+ .transform(&context, &mut input_stream, &mut result, &MockLogger::new())
+ .expect("should transform");
+
+ assert!(!result.is_ascii());
+ assert_content(&transformed_ff, false);
+}
+
+fn public_key_service() -> PGPPublicKeyService {
+ let mut context = MockControllerServiceContext::new();
+ context.properties.insert(
+ "Keyring File".to_string(),
+ test_utils::get_test_key_path("keyring.asc"),
+ );
+
+ PGPPublicKeyService::enable(&context, &MockLogger::new()).expect("should enable")
+}
+
+#[test]
+fn encrypts_ascii_for_alice() {
+ let mut context = MockProcessContext::new();
+ context.properties.extend([
+ ("Public Key Service", "my_controller_service"),
+ ("Public Key Search", "Alice"),
+ ("File Encoding", "ASCII"),
+ ]);
+
+ context.controller_services.insert(
+ "my_controller_service".to_string(),
+ Box::new(public_key_service()),
+ );
+
+ let mut result: Vec = Vec::new();
+ let mut input_stream = std::io::Cursor::new("foo".as_bytes());
+ let processor =
+ EncryptContentPGP::schedule(&context, &MockLogger::new()).expect("should schedule");
+ let transformed_ff = processor
+ .transform(&context, &mut input_stream, &mut result, &MockLogger::new())
+ .expect("should transform");
+
+ assert!(result.is_ascii());
+ assert_content(&transformed_ff, true);
+}
+
+#[test]
+fn encrypts_binary_for_bob() {
+ let mut context = MockProcessContext::new();
+ context.properties.extend([
+ ("Public Key Service", "my_controller_service"),
+ ("Public Key Search", "Bob"),
+ ("File Encoding", "BINARY"),
+ ]);
+
+ context.controller_services.insert(
+ "my_controller_service".to_string(),
+ Box::new(public_key_service()),
+ );
+
+ let mut result: Vec = Vec::new();
+ let mut input_stream = std::io::Cursor::new("foo".as_bytes());
+ let processor =
+ EncryptContentPGP::schedule(&context, &MockLogger::new()).expect("should schedule");
+ let transformed_ff = processor
+ .transform(&context, &mut input_stream, &mut result, &MockLogger::new())
+ .expect("should transform");
+
+ assert!(!result.is_ascii());
+ assert_content(&transformed_ff, false);
+}
+
+#[test]
+fn cannot_encrypt_for_carol() {
+ let mut context = MockProcessContext::new();
+ context.properties.extend([
+ ("Public Key Service", "my_controller_service"),
+ ("Public Key Search", "Carol"),
+ ]);
+
+ context.controller_services.insert(
+ "my_controller_service".to_string(),
+ Box::new(public_key_service()),
+ );
+
+ let mut result: Vec = Vec::new();
+ let mut input_stream = std::io::Cursor::new("foo".as_bytes());
+ let processor =
+ EncryptContentPGP::schedule(&context, &MockLogger::new()).expect("should schedule");
+ let transformed_ff = processor
+ .transform(&context, &mut input_stream, &mut result, &MockLogger::new())
+ .expect("should transform");
+
+ assert_eq!(transformed_ff.target_relationship_name(), FAILURE.name);
+ assert_eq!(transformed_ff.write_status(), IoState::Cancel);
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/processors/mod.rs b/minifi_rust/extensions/minifi_pgp/src/processors/mod.rs
new file mode 100644
index 0000000000..ac45357fe3
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/processors/mod.rs
@@ -0,0 +1,2 @@
+pub(crate) mod decrypt_content;
+pub(crate) mod encrypt_content;
diff --git a/minifi_rust/extensions/minifi_pgp/src/test_utils/mod.rs b/minifi_rust/extensions/minifi_pgp/src/test_utils/mod.rs
new file mode 100644
index 0000000000..963af71617
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/test_utils/mod.rs
@@ -0,0 +1,15 @@
+use std::path::PathBuf;
+
+pub fn get_test_key_path(filename: &str) -> String {
+ let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ path.push("test_keys");
+ path.push(filename);
+ path.display().to_string()
+}
+
+pub fn get_test_message(filename: &str) -> Vec {
+ let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ path.push("test_messages");
+ path.push(filename);
+ std::fs::read(path).expect("test message should be readable")
+}
diff --git a/minifi_rust/extensions/minifi_pgp/src/utils.rs b/minifi_rust/extensions/minifi_pgp/src/utils.rs
new file mode 100644
index 0000000000..95b3462380
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/src/utils.rs
@@ -0,0 +1,11 @@
+use minifi_native::{MinifiError, PropertyType};
+
+pub(crate) struct Password {}
+
+impl PropertyType for Password {
+ type Output = pgp::types::Password;
+
+ fn parse(s: &str) -> Result {
+ Ok(pgp::types::Password::from(s))
+ }
+}
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/README.txt b/minifi_rust/extensions/minifi_pgp/test_keys/README.txt
new file mode 100644
index 0000000000..7b8ad1a612
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_keys/README.txt
@@ -0,0 +1,8 @@
+Testing keys v2
+------------------------
+uid [ultimate] Alice
+passphrase whiterabbit
+
+uid [ultimate] Bob Personal
+uid [ultimate] Bob Primary
+no passphrase
\ No newline at end of file
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/alice.asc b/minifi_rust/extensions/minifi_pgp/test_keys/alice.asc
new file mode 100644
index 0000000000..8ae976d7b2
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_keys/alice.asc
@@ -0,0 +1,50 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+q+zIM+a7/I/TcOmDRO5s
+=EEA6
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/alice.gpg b/minifi_rust/extensions/minifi_pgp/test_keys/alice.gpg
new file mode 100644
index 0000000000..71af439899
Binary files /dev/null and b/minifi_rust/extensions/minifi_pgp/test_keys/alice.gpg differ
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/alice_private.asc b/minifi_rust/extensions/minifi_pgp/test_keys/alice_private.asc
new file mode 100644
index 0000000000..955e525841
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_keys/alice_private.asc
@@ -0,0 +1,92 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+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
+=6nxq
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/alice_private.gpg b/minifi_rust/extensions/minifi_pgp/test_keys/alice_private.gpg
new file mode 100644
index 0000000000..5f385d9eea
Binary files /dev/null and b/minifi_rust/extensions/minifi_pgp/test_keys/alice_private.gpg differ
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/bob_private.asc b/minifi_rust/extensions/minifi_pgp/test_keys/bob_private.asc
new file mode 100644
index 0000000000..09fc1bc992
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_keys/bob_private.asc
@@ -0,0 +1,71 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lQOYBGmJ4P8BCADX7NFGPaQCjP+YL2o74CCNHt+Dx0AkvjUp6mEEb+Chte8/82Rd
+tH9VlxAxrUL560/UzsHAQYGMzbDFzn041GeWcElMr/n7iH3wQSDlxWXE+gSh+vpt
+HKatqzYChSQ8l7u6cmZ3Wuj6ZtcVr0lryZ5x5ja2lPLoK2BnKCoDKwROJLmDR8SY
+S2DwH/n6UU/yqyqlFrdDaZQHyJF26z01rx7nCtfIuWSp5ZS4Fr9qf5eZnFwsY7RN
+r8X74iAbVRkr2uN7jzvijoH2qW6irqHm9bANmcmdQXJE0fSFU6/rJiuwg8IdzCca
+8fJZIzxK5XaMA67rRHXWf5+hFEWyN49oV2UZABEBAAEAB/sEw+zbOJOmht9Ca4k+
+wKdIJSOk9oWiL0rLBPColP5JVgVfSZQlckXreth6EKJPIflfCGBVkLAW8cos+hF2
+BzOU1K31c6ytcF0GgNkEtnyUvsd8VuatW581+X3QS3rhDq7EtCfrCl1WbSv+abdK
+vZuuYxwzu78VMkAY1A6e3KrQvWulVI/jbimbdeHTLKLAo2RYU2HtxPCjnnDfX7GL
+a7zulsH4BSkbguGNqjZGOuNyDjRbMHKdmuPG9KlMLBKJgayjxYZFzmU9BKpu4g6x
+w2fmBq4wGaNentrWH4fsUpc+SsvCDQ7Wchq6gh176KNOF7WRNW0P4L+w3XBUXMBA
+mwK1BADlZEmbI+8sZakxWEF/FeVvOLFhNc1Ot6UN6YDLBcPEXgg0vsxQTVxDOgSs
+oKn4NLFz51n2HBBcXLTSYkAdobAy0EvyXY0soYh/znaJoTyxxSuvTuoz5LZ3vO3v
+kEXEs8NqSfsM/trAMJaOYPWcsp3az5mwHKfy+gzAwxmDchEvNQQA8PikLgc89DIi
+lNyQ3mDwLL2uJkWVgw83Woq0daNVpqeHN1yAU88FNr+EXA4pFZ9l5VBgPC0UL9NX
+t9eCSif67KubgIG5wVM87TKobyZrvYd7EbTbbPVOALes12o6fwzKwd1+4y5swyRx
+PxDegwQ5LDZlCEWTLLakHIlovQXMZtUEAIk20jCFTBzRMuE2KtnC5FW+Ye47QQpV
+78adjNaizEDmotC6/EB1t/TjwndV6saIxXq+K78QW3bI+XK/zASjniqPCDk0uTF/
+Xo7mAaso5x6WSWUhL1YfSAyS5V34wNdYydfzWw+7itws33ckZ7YnDGaslovmztnD
+bT84ZIel57ZwQ6a0GkJvYiBQcmltYXJ5IDxib2JAd29yay5jb20+iQFSBBMBCAA8
+FiEE6sQimyrITfRWlYfRoGdJuk80sOUFAmmJ4P8CGy8FCwkIBwIDIgIBBhUKCQgL
+AgQWAgMBAh4HAheAAAoJEKBnSbpPNLDlEPIH/2F5/cdCz05F4I2UUVkOQxKBMuwe
+PqYyTi/njkXv1VfyK+/mHgVvDY3qaVlrCInxnNYXl2I11cLW7s0kKa9JsZNAWN5j
+oMPL+edkxf3s1X6e+VPd9C8bowWQcqDsoEHrFGwF4FcfVnaol5LIxTdZeVQSjjLj
+ySqGvdLGkQW4CVeAZySLQis15A4Zmb3YdS8ddpTPzPUrc3hUvb84TjjptXHOdjDG
+DEJbnaSr/T7YruSs6TNUmiqGbZQ7tV8oP1ToAV+xNU9dQOIeCigu0zCCGMDe8J7g
+Wjfvpx31WQs4XbKTuxXTTLAdJ01t1SJrQzQV+Bn2Z26LM51rzsb13+CJqxK0GkJv
+YiBQZXJzb25hbCA8Ym9iQGhvbWUuaW8+iQFSBBMBCAA8FiEE6sQimyrITfRWlYfR
+oGdJuk80sOUFAmmJ4QcCGy8FCwkIBwIDIgIBBhUKCQgLAgQWAgMBAh4HAheAAAoJ
+EKBnSbpPNLDlRz0H/199Bi7sNi34bChTfPsujJ6d0SEKzdjJB/aGbmaIwSFLgOho
+B+iC6n6wc6oqx0lMUAbz2LGTwxFo/FMJnkqJnrTWPJHoLKByuXy1MiOx9HO6zfc4
+bo7MBKXblOS/DZz4flJ6QcZWuaea9+8nBasxbKH0C7hPD3tS3CDsFPNKDDVAOfGZ
+UGOT2fOoDfERWMfsGORB3uZVT7va4IZ2rIieYAp4sU13WXdTdnDKrCkSj7qzEKgA
+0OwDlp92re3+dL9P7dI7bHtoEp8bfxNS7WHNG3iBkTxzZdJSUAEFv6FAhR8dIMI2
+XdQ9mFSxmhUCbtYj5c5vdMAzae3Ja4d8taQbvgSdA5gEaYng/wEIAJsLxaaERwjR
+YeYsmqkoVzCfdhl7AlN1F65jEV3Bpet+3/zqoPVaDkB+0oOFs/EN1ac3VtU14cs0
+KtlyxTJIrN8qoOOw4D23gV2pt3jFL11Qf5zHFQKHNpMohhNg0JgV4umXnVwJVcLc
+xyDtmu7gjimbWAkWgYcoqIFsATjy6En0VwtgHst2+FbkAcbXljhOfzHy5Nltb4co
+le+xFgaNFHR2nYSCpItC4Br7M1z6Y22F+C/uDs4vcuY6KSlBPf53K5gE+YP/xhaD
+fqg6Q8YtGeuQK3+a9X1Bdy7zuAHBPQQR9m4SObeCXIjVAWW50TUHp6FMLFup/+C5
+jgm9dqIxzX8AEQEAAQAH/isjht5SXptO+rC6x1t6fGvsakUjqx2CblDYgpv2Bc60
+seiidZ9ea6m5P6RVfp/6y+/nH1NaVxUdUjDHVKOtgd/j8fj4HSQ+2xEu5/wDzS5m
+9+Ksp6VY7q/aLhfVL6SpLkX1J9TUShbaK9N3GM0PEK716HQ63VY4U04TOXHZcBUn
+JnRfdJToEwNTvtDo/9itVXCJsczWVT1aJRdHKekFDHEQSpTZaDFnWMR1Mluh1Tyt
+w4Y25KoOkslM4WxuUmx27u+NOBq6/Z8GOHHuiBOGRlbr9KsuS2Ul6aSwYCOUF/qa
+HrnvDNoa+w0Unr1zX71QkVRrSFMNazzTxP+Q7yghlr0EAMV64xrQ7ogDP1TzKA7H
+lBFYn91BZgH+AZSysPp0osjjTuhtLI4bWDQk+Sg2tVszE+XESKAlYIIz3eQlD7n1
+PgNhQ2IniAcPyrhGzLQalY+woil+a5jIQHkw3z7So+ooSgfIboHiwRbq+lzaUolf
+ZpKoJ3VsGOhxU/2XnQQ2jJQLBADI/cdY7O06asiQiuFddAn7NKQmsMV9uj4SfmKO
+SF7taSWUEZZb66FwslwqH2UM92abIr2+OI+G3PJFAxLnsngjjZSJcSHeqBlWemBN
+C7/VsFRTO/YNdLTcHwetz6MKVe2/4ZyTOKsLpbwYgFa/I1NWT/mcRwc/2dQniEK8
+IEIA3QP/XDmNeqLAMKl72aKadibcLnuuZDxbNyKFXBbCcduqnVK6xCVYVfRwd0dx
+H/KtwiXlhqHokzdXryE26jEq/haEDV0GwXso9rmZWwPAO7wLec2KDfygGFj2pWJg
+44LSInrNKMdxxAANCbHMy2pwNJkQAcIVj4QlqsCSI1OXroPJJmA5p4kCbAQYAQgA
+IBYhBOrEIpsqyE30VpWH0aBnSbpPNLDlBQJpieD/AhsuAUAJEKBnSbpPNLDlwHQg
+BBkBCAAdFiEEAmTl3msGKezJa2F7ijYk4nulVAsFAmmJ4P8ACgkQijYk4nulVAte
+6gf7Be1QehFVqh9EbQlCm3iyNZsqTe8WFsnAi+0xCU+N1/ea0X1M64dx+nj2ec1R
+GNGRSKmNuuwvgNdqcFCo9FAkGRsIFNhSgBAu3gwAZlRXTdijE7V1oEOS7aYYEVQM
+Vscjs+ywJHRDkPGju0ajD7Upt1uc+ZuCdTwzXv95amfjOIKgwoLjItnEmLVFIUBV
+hsKRfGzHHuI6yHQcMZiW7ogLguKVdUBQq+ZkBKKC+o+xhLjQdrVl+AUkdPCI8hLQ
+kzBSuv5/VUOjYwnsfjIPjrAJY/ZxH6I46tOfFSNOOApebDpUKbCb8Ozvl2OI/Y/o
+idKR2YI3wiluEPyJAtfFg0P0Vj4AB/90Q1dIPHXryirzLUtsRXNN9zUlUYWlK8JQ
+e7FQLV3lkbqCfs0fij335fy6Z4KVaAOPN/G9Hxh4uTLLBLLMAU7BARAnUH7rap8X
+9FpGpg4HmmdG0F7emquun4P9UvDGg3qvGfWmQg9Xc5AdyUN/VHcBYVXh8mWPWnmv
+/QHewpu7z9tCakhRjchc1Vka9lbozguzlXgntANxdo/ZVDxOMTk5q3nn+/6NiU6E
+SFn2kXlUXMo0kVcU5LROYW10zjmv3oqpInN++FyWyQY31kaGk8Wrx9UKW134biZt
+vDO1S0dhI+RhZ2Up8i2xFpym+h4Urku66ew2ha1fGYwirmQUaVfA
+=ePbg
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/bob_private.gpg b/minifi_rust/extensions/minifi_pgp/test_keys/bob_private.gpg
new file mode 100644
index 0000000000..a393702430
Binary files /dev/null and b/minifi_rust/extensions/minifi_pgp/test_keys/bob_private.gpg differ
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/garbage.gpg b/minifi_rust/extensions/minifi_pgp/test_keys/garbage.gpg
new file mode 100644
index 0000000000..9e603b5c3b
Binary files /dev/null and b/minifi_rust/extensions/minifi_pgp/test_keys/garbage.gpg differ
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/keyring.asc b/minifi_rust/extensions/minifi_pgp/test_keys/keyring.asc
new file mode 100644
index 0000000000..0fa816a55d
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_keys/keyring.asc
@@ -0,0 +1,89 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+=fkCr
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/keyring.gpg b/minifi_rust/extensions/minifi_pgp/test_keys/keyring.gpg
new file mode 100644
index 0000000000..6a84506f9d
Binary files /dev/null and b/minifi_rust/extensions/minifi_pgp/test_keys/keyring.gpg differ
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/secret_keyring.asc b/minifi_rust/extensions/minifi_pgp/test_keys/secret_keyring.asc
new file mode 100644
index 0000000000..6430facf86
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_keys/secret_keyring.asc
@@ -0,0 +1,159 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+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
+=f4lJ
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/secret_keyring.gpg b/minifi_rust/extensions/minifi_pgp/test_keys/secret_keyring.gpg
new file mode 100644
index 0000000000..4a07229aa2
Binary files /dev/null and b/minifi_rust/extensions/minifi_pgp/test_keys/secret_keyring.gpg differ
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/truncated.asc b/minifi_rust/extensions/minifi_pgp/test_keys/truncated.asc
new file mode 100644
index 0000000000..d25ddfb620
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_keys/truncated.asc
@@ -0,0 +1,10 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBGmJ4OIBCACz9RXNN6lFaUi0b4V6PTyjc27g9G0OCBMy6H/lcjROMGupqPm1
+9QzEzTIrxkc1LlPx31qzb6SwzQWkKiDnmObcZzG43Yiz1aD0YOqJsHBb9klrdWFx
+VbGTtaDmZg/xAS+VseYTijiucydURPzIKDb25vWl7r+iAdhZY3eo8Zif7g7LDpU6
+hsqAQOVgIGCokbbS4GFTeOIl6uwS1Gchq40vY5AM7o4/AObANNstyROgQrQqq19Y
+QEjnLT6GsxF6jpbrcb+8No6JWJSaqhDjIVug+psaeuqruQkN6o3B85izGk0fu4QD
+kSYGW3/A9ArGrLhGMtFnTyo/fg9sEGqWxLoJABEBAAG0GUFsaWNlIDxhbGljZUBl
+eGFtcGxlLmNvbT6JAVIEEwEIADwWIQQR1fT4Ba73eK2U4NIbsOxL81Ml9gUCaYng
+4gIbLwU
\ No newline at end of file
diff --git a/minifi_rust/extensions/minifi_pgp/test_keys/truncated_private.asc b/minifi_rust/extensions/minifi_pgp/test_keys/truncated_private.asc
new file mode 100644
index 0000000000..9cc58539ae
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_keys/truncated_private.asc
@@ -0,0 +1,17 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lQPGBGmJ4OIBCACz9RXNN6lFaUi0b4V6PTyjc27g9G0OCBMy6H/lcjROMGupqPm1
+9QzEzTIrxkc1LlPx31qzb6SwzQWkKiDnmObcZzG43Yiz1aD0YOqJsHBb9klrdWFx
+VbGTtaDmZg/xAS+VseYTijiucydURPzIKDb25vWl7r+iAdhZY3eo8Zif7g7LDpU6
+hsqAQOVgIGCokbbS4GFTeOIl6uwS1Gchq40vY5AM7o4/AObANNstyROgQrQqq19Y
+QEjnLT6GsxF6jpbrcb+8No6JWJSaqhDjIVug+psaeuqruQkN6o3B85izGk0fu4QD
+kSYGW3/A9ArGrLhGMtFnTyo/fg9sEGqWxLoJABEBAAH+BwMCYkH6w6nO395gtdQ6
+zQJvZ1itO9NCbRtI20iVkqWmwtr2FwgN8AZ9sGZss6zdpfxh85Ef1kHvP1nkbedk
+/8OmBljPooqKB7MTwCCxOC53Mf6wNMijlBYsY8YUyi4dwaHoxnDFnaCeITSHHehY
+07ifnInvrTkbJ41JzfP124xQ804voehm7merA91Vtpvg/hoYqJ/Sxo22UpTwvuw/
+aKqoetlJWqRk8VmBpcuuVFYcF9jaOPB51WG8fRDj66eINg2zXL49WRvwlUtbAHvS
+cbglkBzMFHqljx0KJWX/QMO64X894eFafVFvSiYf+fn80wv9h7IKjj413itlbF0r
+X+DckGQ9b50XAD3kZgDOMr5dKTGQ9ytChl7hpy38ucQqJM0qRlTSG2mp05qBO+XZ
+CNNE7qvVNJNP5nD/3xkWD8oi+nhmqqg4bZ/QDUcoTVrWW7L1er2fgREJg7xL7xuD
+QYu/T0A6N9PxWefYdEN/jjcLqks/Pjdy3DfGtlDysj88GpLh3diNgQ2EnNgQ32pq
+JmxwEWIQ4VV30Ms1D0Uh7g4Ksq0lq1/LjOll3FSyjr3Ihpesopj
\ No newline at end of file
diff --git a/minifi_rust/extensions/minifi_pgp/test_messages/foo_for_alice.asc b/minifi_rust/extensions/minifi_pgp/test_messages/foo_for_alice.asc
new file mode 100644
index 0000000000..94b8561c9a
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_messages/foo_for_alice.asc
@@ -0,0 +1,12 @@
+-----BEGIN PGP MESSAGE-----
+
+hQEMA7zOP9+6AZ1+AQf/WlAEDriFTKHJfn5KXAi123WGeDJBoRi/etl7GJ8MO5+8
+crdou58wMcqRJ8u3wNgKWDYm+QknLhQK5+3dJajwQeKH18uruTkEmFQB/wArHsOX
+62UhFf2qbAzvUuTH5kPyt1d/Wt51T9+K/xlEPJr+DiK0uHlXZPu7rEnqk9pcKikC
+/dYAuljnkNigDoykHwEBRcBfQu5t/hIe/Bii3wTZPm2w0YneyjOtd7Yq3mDlfmDW
+cy3bdDjuwP4npCxcnHi7WkbElTyCJMybKVwwLjugihGI+4r8itO2wknAT5GDGMQ8
+u2FfOfGnIYTK2mBAQgyM7gtBX2qS28uWYlj5gehngdRJAQkCEFMqwWDAaUXAi6QU
+xhn/O+wEEpUVYnGuEpIqG9KTW3qYl+vTxLkeNzg2NL255QP9gLbdlKFJYkC60c4I
+JJXhkwkPTSC+Xw==
+=UAju
+-----END PGP MESSAGE-----
diff --git a/minifi_rust/extensions/minifi_pgp/test_messages/foo_for_alice.gpg b/minifi_rust/extensions/minifi_pgp/test_messages/foo_for_alice.gpg
new file mode 100644
index 0000000000..bd3d9b476e
Binary files /dev/null and b/minifi_rust/extensions/minifi_pgp/test_messages/foo_for_alice.gpg differ
diff --git a/minifi_rust/extensions/minifi_pgp/test_messages/password_encrypted_foo.asc b/minifi_rust/extensions/minifi_pgp/test_messages/password_encrypted_foo.asc
new file mode 100644
index 0000000000..22d639e711
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_messages/password_encrypted_foo.asc
@@ -0,0 +1,6 @@
+-----BEGIN PGP MESSAGE-----
+
+jA0ECQMCU2B2LnRTkyNg0jkBhgVPotvo6S9iLOTWhzglgsjR/6QB2v7vUNImzkh7
+fjhd17fG5tjB1RPRgW3bR12BidV6TQKuwLs=
+=AXoJ
+-----END PGP MESSAGE-----
diff --git a/minifi_rust/extensions/minifi_pgp/test_messages/password_encrypted_foo.gpg b/minifi_rust/extensions/minifi_pgp/test_messages/password_encrypted_foo.gpg
new file mode 100644
index 0000000000..40ab6a354e
--- /dev/null
+++ b/minifi_rust/extensions/minifi_pgp/test_messages/password_encrypted_foo.gpg
@@ -0,0 +1 @@
+
t?`9B2;g]N)!r2!Fnl./a9._}_2,ɴ
\ No newline at end of file
diff --git a/minifi_rust/extensions/minifi_rs_playground/.cargo/config.toml b/minifi_rust/extensions/minifi_rs_playground/.cargo/config.toml
new file mode 100644
index 0000000000..cb8c02ddc4
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/.cargo/config.toml
@@ -0,0 +1,5 @@
+[target.aarch64-apple-darwin]
+rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
+
+[target.x86_64-apple-darwin]
+rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"]
diff --git a/minifi_rust/extensions/minifi_rs_playground/.gitignore b/minifi_rust/extensions/minifi_rs_playground/.gitignore
new file mode 100644
index 0000000000..32c9b263bd
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/.gitignore
@@ -0,0 +1,7 @@
+target
+output
+features/.venv
+features/output
+integration-test/features/.venv
+integration-test/features/linux_so
+integration-test/.venv
diff --git a/minifi_rust/extensions/minifi_rs_playground/Cargo.toml b/minifi_rust/extensions/minifi_rs_playground/Cargo.toml
new file mode 100644
index 0000000000..ec90190e37
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "minifi_rs_playground"
+version = "0.1.0"
+edition = "2024"
+description = "This extension contains reimplentations of already existing processors and test only processors. These are not intended for production usage."
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+minifi_native = { path = "../../minifi_native" }
+strum = "0.28.0"
+walkdir = "2.5.0"
+rand = "0.10.0"
+hex = "0.4.3"
+strum_macros = "0.28.0"
+lipsum = "0.9.1"
+
+
+[dev-dependencies]
+tempfile = "3.22.0"
+filetime = "0.2.26"
diff --git a/minifi_rust/extensions/minifi_rs_playground/features/basic.feature b/minifi_rust/extensions/minifi_rs_playground/features/basic.feature
new file mode 100644
index 0000000000..698266740d
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/features/basic.feature
@@ -0,0 +1,99 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+@SUPPORTS_WINDOWS
+Feature: Basic scenarios
+
+ Scenario: The rust library is loaded into minifi
+ Given log property "logger.org::apache::nifi::minifi::core::extension::ExtensionManager" is set to "TRACE,stderr"
+ And log property "logger.org::apache::nifi::minifi::core::ClassLoader" is set to "TRACE,stderr"
+
+ When the MiNiFi instance starts up
+
+ Then the Minifi logs contain the following message: "Registering class 'GenerateFlowFileRs' at '/minifi_rs_playground'" in less than 10 seconds
+ And the Minifi logs contain the following message: "Registering class 'GetFileRs' at '/minifi_rs_playground'" in less than 1 seconds
+ And the Minifi logs contain the following message: "Registering class 'KamikazeProcessorRs' at '/minifi_rs_playground'" in less than 1 seconds
+ And the Minifi logs contain the following message: "Registering class 'LogAttributeRs' at '/minifi_rs_playground'" in less than 1 seconds
+ And the Minifi logs contain the following message: "Registering class 'PutFileRs' at '/minifi_rs_playground'" in less than 1 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
+
+ Scenario: Simple GenerateFlowFileRs -> PutFileRs
+ Given a GenerateFlowFileRs processor with the "Custom Text" property set to "Ferris the crab"
+ And the "Data Format" property of the GenerateFlowFileRs processor is set to "Text"
+ And the "Unique FlowFiles" property of the GenerateFlowFileRs processor is set to "false"
+ And a PutFileRs processor with the "Directory" property set to "/tmp/output"
+ And the "success" relationship of the GenerateFlowFileRs processor is connected to the PutFileRs
+ And PutFileRs's success relationship is auto-terminated
+
+ When the MiNiFi instance starts up
+
+ Then at least one file with the content "Ferris the crab" is placed in the "/tmp/output" directory in less than 10 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
+
+ Scenario: Simple GetFileRs -> PutFileRs
+ Given a GetFileRs processor with the "Input Directory" property set to "/tmp/input"
+ And a PutFileRs processor with the "Directory" property set to "/tmp/output"
+ And the "success" relationship of the GetFileRs processor is connected to the PutFileRs
+ And PutFileRs's success relationship is auto-terminated
+ And PutFileRs's failure relationship is auto-terminated
+ And a directory at "/tmp/input" has a file "test_file.log" with the content "test content"
+
+ When the MiNiFi instance starts up
+
+ Then at least one file with the content "test content" is placed in the "/tmp/output" directory in less than 10 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
+
+ Scenario Outline: The LogAttributeRs can read and log FlowFile content
+ Given a GenerateFlowFileRs processor with the "Custom Text" property set to ""
+ And the "Data Format" property of the GenerateFlowFileRs processor is set to "Text"
+ And the "Unique FlowFiles" property of the GenerateFlowFileRs processor is set to "false"
+ And a LogAttributeRs processor with the "Log Level" property set to ""
+ And the "Log Payload" property of the LogAttributeRs processor is set to "true"
+ And the "success" relationship of the GenerateFlowFileRs processor is connected to the LogAttributeRs
+ And LogAttributeRs's success relationship is auto-terminated
+ And log property "logger.minifi_rs_playground::processors::log_attribute::LogAttributeRs" is set to "TRACE,stderr"
+
+ When the MiNiFi instance starts up
+
+ Then the Minifi logs contain the following message: "" in less than 20 seconds
+ And the Minifi logs contain the following message: "" in less than 1 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
+ Examples:
+ | custom_text | log_level | expected_log_1 | expected_log_2 |
+ | Elephant | Critical | [critical] Logging for flow file | Elephant |
+ | Lynx | Info | [info] Logging for flow file | Lynx |
+ | Ant | Trace | [trace] Logging for flow file | Ant |
+
+ Scenario Outline: Controller services work
+ Given a LoremIpsumCSUser processor with the "Lorem Ipsum Controller Service" property set to "LoremIpsumControllerService"
+ And the "Write Method" property of the LoremIpsumCSUser processor is set to ""
+ And a LoremIpsumControllerService controller service is set up and the "Length" property set to "200000"
+ And a PutFileRs processor with the "Directory" property set to "/tmp/output"
+ And the "success" relationship of the LoremIpsumCSUser processor is connected to the PutFileRs
+ And PutFileRs's success relationship is auto-terminated
+
+ When the MiNiFi instance starts up
+
+ Then at least one file with minimum size of "1 MB" is placed in the "/tmp/output" directory in less than 10 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
+ Examples:
+ | write_method |
+ | Buffer |
+ | Stream |
diff --git a/minifi_rust/extensions/minifi_rs_playground/features/controller_apis.feature b/minifi_rust/extensions/minifi_rs_playground/features/controller_apis.feature
new file mode 100644
index 0000000000..c93f896160
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/features/controller_apis.feature
@@ -0,0 +1,28 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+@SUPPORTS_WINDOWS
+Feature: Testing controller service api casting
+
+ Scenario: Zoo has a jetpack dog
+ Given a ZooProcessorRs processor with the "Can fly service" property set to "Wolfie the magical"
+ And the "Number of Legs service" property of the ZooProcessorRs processor is set to "Wolfie the magical"
+ And a DogControllerRs controller service named "Wolfie the magical" is set up and the "Has Jetpack" property set to "true"
+ And the "Extra information" property of the Wolfie the magical controller service is set to "The dog (Canis familiaris or Canis lupus familiaris) is a domesticated descendant of wolves."
+ When the MiNiFi instance starts up
+
+ Then the Minifi logs contain the following message: "[minifi_rs_playground::processors::zoo_processor::ZooProcessorRs] [critical] Can DogControllerRs { has_jetpack: true, extra_info: "The dog (Canis familiaris or Canis lupus familiaris) is a domesticated descendant of wolves." } fly? true" in less than 10 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
diff --git a/minifi_rust/extensions/minifi_rs_playground/features/environment.py b/minifi_rust/extensions/minifi_rs_playground/features/environment.py
new file mode 100644
index 0000000000..3ba4288e03
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/features/environment.py
@@ -0,0 +1,91 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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
+#
+# https://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.
+
+import os
+from typing import List
+
+from minifi_behave.containers.docker_image_builder import DockerImageBuilder
+from minifi_behave.core.hooks import common_after_scenario
+from minifi_behave.core.hooks import common_before_scenario, get_minifi_container_image
+from minifi_behave.core.minifi_test_context import MinifiTestContext
+
+
+def add_extension_to_minifi_container(
+ extension_name: str, possible_paths: List[str], context: MinifiTestContext
+):
+ new_container_name = f"apacheminificpp:{extension_name}"
+ is_windows = os.name == "nt"
+ if is_windows:
+ lib_filename = f"{extension_name}.dll"
+ container_extension_dir = (
+ "C:/Program Files/ApacheNiFiMiNiFi/nifi-minifi-cpp/extensions"
+ )
+ else:
+ lib_filename = f"lib{extension_name}.so"
+ container_extension_dir = "/opt/minifi/minifi-current/extensions/"
+
+ host_path = None
+ for path in possible_paths:
+ if os.path.exists(os.path.join(path, lib_filename)):
+ host_path = os.path.join(path, lib_filename)
+ break
+
+ assert host_path is not None, (
+ f"Could not find {lib_filename} in {[p for p in possible_paths]}"
+ )
+
+ with open(host_path, "rb") as f:
+ lib_content = f.read()
+
+ base_img = get_minifi_container_image()
+
+ if is_windows:
+ dockerfile = f"""
+FROM {base_img}
+COPY ["{lib_filename}", "{container_extension_dir}/{lib_filename}"]
+"""
+ else:
+ dockerfile = f"""
+FROM {base_img}
+COPY --chown=minificpp:minificpp {lib_filename} {container_extension_dir}
+RUN chmod 755 {container_extension_dir}{lib_filename}
+"""
+
+ builder = DockerImageBuilder(
+ image_tag=new_container_name,
+ dockerfile_content=dockerfile,
+ files_on_context={lib_filename: lib_content},
+ )
+
+ builder.build()
+ return new_container_name
+
+
+def before_all(context):
+ dir_path = os.path.dirname(os.path.realpath(__file__))
+ build_path = os.path.normpath(os.path.join(dir_path, "../../../target/release/"))
+ deps_build_path = os.path.normpath(os.path.join(dir_path, "../../../target/release/deps/"))
+ add_extension_to_minifi_container("minifi_rs_playground", [build_path, deps_build_path], context)
+
+
+def before_scenario(context, scenario):
+ context.minifi_container_image = "apacheminificpp:minifi_rs_playground"
+ common_before_scenario(context, scenario)
+
+
+def after_scenario(context, scenario):
+ common_after_scenario(context, scenario)
diff --git a/minifi_rust/extensions/minifi_rs_playground/features/error-handling.feature b/minifi_rust/extensions/minifi_rs_playground/features/error-handling.feature
new file mode 100644
index 0000000000..5eb3329f8a
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/features/error-handling.feature
@@ -0,0 +1,85 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+@SUPPORTS_WINDOWS
+Feature: API error handling and logging
+
+ Scenario: The Api handles empty flow-files
+ Given a GenerateFlowFileRs processor with the "Custom Text" property set to "${invalid_attribute}"
+ And the "Data Format" property of the GenerateFlowFileRs processor is set to "Text"
+ And the "Unique FlowFiles" property of the GenerateFlowFileRs processor is set to "false"
+ And a LogAttributeRs processor with the "Log Level" property set to "Critical"
+ And the "success" relationship of the GenerateFlowFileRs processor is connected to the LogAttributeRs
+ And LogAttributeRs's success relationship is auto-terminated
+
+ When the MiNiFi instance starts up
+
+ Then after 3 sec have passed
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
+
+ Scenario: Minifi handles errors from schedule
+ Given a KamikazeProcessorRs processor with the "Schedule Behaviour" property set to "ReturnErr"
+ And KamikazeProcessorRs's success relationship is auto-terminated
+
+ When the MiNiFi instance starts up
+
+ Then the Minifi logs contain the following message: "KamikazeProcessorRs] [error] Error during schedule: ScheduleError("it was designed to fail during schedule")" in less than 10 seconds
+ And the Minifi logs contain the following message: "(KamikazeProcessorRs): Process Schedule Operation: Error while scheduling processor" in less than 10 seconds
+
+ Scenario: Minifi handles errors from trigger
+ Given a KamikazeProcessorRs processor with the "Schedule Behaviour" property set to "ReturnOk"
+ And the "Trigger Behaviour" property of the KamikazeProcessorRs processor is set to "ReturnErr"
+ And KamikazeProcessorRs's success relationship is auto-terminated
+
+ When the MiNiFi instance starts up
+
+ Then the Minifi logs contain the following message: "KamikazeProcessorRs] [error] Error during trigger TriggerError("it was designed to fail in trigger")" in less than 10 seconds
+ And the Minifi logs contain the following message: "Trigger and commit failed for processor KamikazeProcessorRs" in less than 10 seconds
+
+ Scenario: Panic in extension's schedule crashes the agent aswell
+ Given a KamikazeProcessorRs processor with the "Schedule Behaviour" property set to "Panic"
+ And KamikazeProcessorRs's success relationship is auto-terminated
+
+ When the MiNiFi instance is started without assertions
+ Then Minifi crashes with the following "KamikazeProcessor::schedule panic" in less than 10 seconds
+
+ Scenario: Panic in extension's trigger crashes the agent aswell
+ Given a KamikazeProcessorRs processor with the "Schedule Behaviour" property set to "ReturnOk"
+ And the "Trigger Behaviour" property of the KamikazeProcessorRs processor is set to "Panic"
+ And KamikazeProcessorRs's success relationship is auto-terminated
+
+ When the MiNiFi instance is started without assertions
+ Then Minifi crashes with the following "KamikazeProcessor::trigger panic" in less than 10 seconds
+
+ Scenario: Get not supported property
+ Given a KamikazeProcessorRs processor with the "Schedule Behaviour" property set to "ReturnOk"
+ And the "Trigger Behaviour" property of the KamikazeProcessorRs processor is set to "GetNotRegisteredProperty"
+ And KamikazeProcessorRs's success relationship is auto-terminated
+
+ When the MiNiFi instance starts up
+
+ Then the Minifi logs contain the following message: "minifi_process_context_get_property("Kamikaze Processor Property"), not supported property" in less than 10 seconds
+ And the Minifi logs contain the following message: "Trigger and commit failed for processor KamikazeProcessorRs" in less than 10 seconds
+
+ Scenario: Get wrong typed Controller Service
+ Given a LoremIpsumCSUser processor with the "Lorem Ipsum Controller Service" property set to "My Controller Service"
+ And a DummyControllerService controller service is set up
+ And LoremIpsumCSUser's success relationship is auto-terminated
+
+ When the MiNiFi instance starts up
+
+ Then the Minifi logs contain the following message: "Error during trigger minifi_process_context_get_controller_service_from_property::<"minifi_rs_playground::controller_services::lorem_ipsum_controller_service::LoremIpsumControllerService">" in less than 10 seconds
+ And the Minifi logs contain the following message: "Trigger and commit failed for processor LoremIpsumCSUser" in less than 10 seconds
diff --git a/minifi_rust/extensions/minifi_rs_playground/features/lazy-logging.feature b/minifi_rust/extensions/minifi_rs_playground/features/lazy-logging.feature
new file mode 100644
index 0000000000..ece06e3663
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/features/lazy-logging.feature
@@ -0,0 +1,28 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+@SUPPORTS_WINDOWS
+Feature: Logs should be lazily evaluated
+
+ Scenario: CountActualLogging only increments self when actually logging
+ Given a CountActualLogging processor
+ And log property "logger.minifi_rs_playground::processors::count_actual_logging::CountActualLogging" is set to "INFO,stderr"
+ And CountActualLogging is TIMER_DRIVEN with 1 min scheduling period
+
+ When the MiNiFi instance starts up
+
+ Then the Minifi logs contain the following message: "[minifi_rs_playground::processors::count_actual_logging::CountActualLogging] [info] info 1" in less than 10 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
diff --git a/minifi_rust/extensions/minifi_rs_playground/features/metrics.feature b/minifi_rust/extensions/minifi_rs_playground/features/metrics.feature
new file mode 100644
index 0000000000..8c335649b2
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/features/metrics.feature
@@ -0,0 +1,38 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+@SUPPORTS_WINDOWS
+Feature: Testing custom and default metrics
+
+ Scenario: CustomMetrics(GetFileRs), DefaultMetrics from streaming(DuplicateStreamText) API and DefaultMetrics from buffer(PutFileRs) API
+ Given a GetFileRs processor with the "Input Directory" property set to "/tmp/input"
+ And a DuplicateStreamText processor
+ And a PutFileRs processor with the "Directory" property set to "/tmp/output"
+ And the "success" relationship of the GetFileRs processor is connected to the DuplicateStreamText
+ And the "success" relationship of the DuplicateStreamText processor is connected to the PutFileRs
+ And PutFileRs's success relationship is auto-terminated
+ And PutFileRs's failure relationship is auto-terminated
+ And a directory at "/tmp/input" has a file "hello.txt" with the content "hello"
+ And MiNiFi logs processor metrics
+
+ When the MiNiFi instance starts up
+
+ Then at least one file with the content "hheelllloo" is placed in the "/tmp/output" directory in less than 10 seconds
+ And the Minifi logs match the following regex: "GetFileRsMetrics": {\n[ ]+\"[0-9a-z-]+\": \{\n[ a-zA-Z0-9":,\n]*"BytesRead": "0",[\n ]*"BytesWritten": "5"[ a-zA-Z0-9":,\n]*"InputBytes": "5"[ a-zA-Z0-9":,\n]*}" in less than 10 seconds
+ And the Minifi logs match the following regex: "DuplicateStreamTextMetrics": {\n[ ]+\"[0-9a-z-]+\": \{\n[ a-zA-Z0-9":,\n]*"BytesRead": "5",[\n ]*"BytesWritten": "10"[ a-zA-Z0-9":,\n]*}" in less than 10 seconds
+ And the Minifi logs match the following regex: "PutFileRsMetrics": {\n[ ]+\"[0-9a-z-]+\": \{\n[ a-zA-Z0-9":,\n]*"BytesRead": "10"[ a-zA-Z0-9":,\n]*}" in less than 10 seconds
+
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
diff --git a/minifi_rust/extensions/minifi_rs_playground/features/steps/steps.py b/minifi_rust/extensions/minifi_rs_playground/features/steps/steps.py
new file mode 100644
index 0000000000..f9a0ed64a5
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/features/steps/steps.py
@@ -0,0 +1,62 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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
+#
+# https://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.
+
+from behave import then, when, given
+import humanfriendly
+
+from minifi_behave.steps import checking_steps # noqa: F401
+from minifi_behave.steps import configuration_steps # noqa: F401
+from minifi_behave.steps import core_steps # noqa: F401
+from minifi_behave.steps import flow_building_steps # noqa: F401
+from minifi_behave.core.helpers import wait_for_condition
+from minifi_behave.core.minifi_test_context import MinifiTestContext
+
+
+@when("the MiNiFi instance is started without assertions")
+def minifi_starts_wo_assertions(context: MinifiTestContext):
+ context.get_or_create_default_minifi_container().deploy(context)
+
+
+@then('Minifi crashes with the following "{crash_msg}" in less than {duration}')
+def minifi_crashes(context: MinifiTestContext, crash_msg: str, duration: str):
+ duration_seconds = humanfriendly.parse_timespan(duration)
+ assert wait_for_condition(
+ condition=lambda: (
+ context.get_or_create_default_minifi_container().exited
+ and crash_msg in context.get_or_create_default_minifi_container().get_logs()
+ ),
+ timeout_seconds=duration_seconds,
+ bail_condition=lambda: False,
+ context=context,
+ )
+
+
+@given("MiNiFi logs processor metrics")
+def minifi_logs_processor_metrics(context: MinifiTestContext):
+ context.get_or_create_default_minifi_container().set_property(
+ "nifi.metrics.publisher.LogMetricsPublisher.metrics",
+ "GetFileRsMetrics,DuplicateStreamTextMetrics,PutFileRsMetrics",
+ )
+ context.get_or_create_default_minifi_container().set_property(
+ "nifi.metrics.publisher.LogMetricsPublisher.logging.interval", "1s"
+ )
+ context.get_or_create_default_minifi_container().set_property(
+ "nifi.metrics.publisher.class", "LogMetricsPublisher"
+ )
+ context.get_or_create_default_minifi_container().set_property(
+ "nifi.metrics.publisher.agent.identifier", "Agent1"
+ )
diff --git a/minifi_rust/extensions/minifi_rs_playground/features/streaming.feature b/minifi_rust/extensions/minifi_rs_playground/features/streaming.feature
new file mode 100644
index 0000000000..2189156516
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/features/streaming.feature
@@ -0,0 +1,49 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+
+@SUPPORTS_WINDOWS
+Feature: Testing streaming reads and writes
+
+ Scenario: Streaming Transforms work
+ Given a GetFileRs processor with the "Input Directory" property set to "/tmp/input"
+ And a AsciifyGerman processor
+ And a PutFileRs processor with the "Directory" property set to "/tmp/output"
+ And the "success" relationship of the GetFileRs processor is connected to the AsciifyGerman
+ And the "success" relationship of the AsciifyGerman processor is connected to the PutFileRs
+ And PutFileRs's success relationship is auto-terminated
+ And PutFileRs's failure relationship is auto-terminated
+ And a directory at "/tmp/input" has a file "german.txt" with the content "Üben von Xylophon und Querflöte ist ja zweckmäßig."
+
+ When the MiNiFi instance starts up
+
+ Then at least one file with the content "Ueben von Xylophon und Querfloete ist ja zweckmaessig." is placed in the "/tmp/output" directory in less than 10 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
+
+ Scenario: Streaming can be cancelled
+ Given a GetFileRs processor with the "Input Directory" property set to "/tmp/input"
+ And a AsciifyGerman processor
+ And a PutFileRs processor with the "Directory" property set to "/tmp/output"
+ And the "success" relationship of the GetFileRs processor is connected to the AsciifyGerman
+ And the "failure" relationship of the AsciifyGerman processor is connected to the PutFileRs
+ And PutFileRs's success relationship is auto-terminated
+ And PutFileRs's failure relationship is auto-terminated
+ And a directory at "/tmp/input" has a file "french.txt" with the content "Voix ambiguë d'un cœur qui, au zéphyr, préfère les jattes de kiwis."
+
+ When the MiNiFi instance starts up
+
+ Then at least one file with the content "Voix ambiguë d'un cœur qui, au zéphyr, préfère les jattes de kiwis." is placed in the "/tmp/output" directory in less than 10 seconds
+ And the Minifi logs do not contain errors
+ And the Minifi logs do not contain warnings
diff --git a/minifi_rust/extensions/minifi_rs_playground/minifi_rs_playground.md b/minifi_rust/extensions/minifi_rs_playground/minifi_rs_playground.md
new file mode 100644
index 0000000000..4078b7301a
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/minifi_rs_playground.md
@@ -0,0 +1,335 @@
+
+
+## Table of Contents
+
+### Processors
+
+- [AsciifyGerman](#AsciifyGerman)
+- [CountActualLogging](#CountActualLogging)
+- [DuplicateStreamText](#DuplicateStreamText)
+- [GenerateFlowFileRs](#GenerateFlowFileRs)
+- [GetFileRs](#GetFileRs)
+- [KamikazeProcessorRs](#KamikazeProcessorRs)
+- [LogAttributeRs](#LogAttributeRs)
+- [LoremIpsumCSUser](#LoremIpsumCSUser)
+- [PutFileRs](#PutFileRs)
+- [ZooProcessorRs](#ZooProcessorRs)
+### Controller Services
+
+- [DogControllerRs](#DogControllerRs)
+- [DuckControllerRs](#DuckControllerRs)
+- [DummyControllerService](#DummyControllerService)
+- [LoremIpsumControllerService](#LoremIpsumControllerService)
+
+
+## AsciifyGerman
+
+### Description
+
+RUST TEST PROCESSOR: This processor switches German characters with their ascii counterparts. (to test stream API)
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|------|---------------|------------------|-------------|
+
+### Relationships
+
+| Name | Description |
+|---------|-----------------------------------------|
+| failure | Non-german flowfiles are routed here |
+| success | All asciified flowfiles are routed here |
+
+
+## CountActualLogging
+
+### Description
+
+RUST TEST PROCESSOR: For testing lazy logging
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|------|---------------|------------------|-------------|
+
+### Relationships
+
+| Name | Description |
+|------|-------------|
+
+
+## DuplicateStreamText
+
+### Description
+
+RUST TEST PROCESSOR: Duplicate text
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|------|---------------|------------------|-------------|
+
+### Relationships
+
+| Name | Description |
+|---------|-------------|
+| success | |
+
+
+## GenerateFlowFileRs
+
+### Description
+
+RUST TEST PROCESSOR: This processor creates FlowFiles with random data or custom content. GenerateFlowFile is useful for load testing, configuration, and simulation.
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|----------------------|---------------|------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Batch Size** | 1 | | The number of FlowFiles to be transferred in each invocation |
+| Custom Text | | | If Data Format is text and if Unique FlowFiles is false, then this custom text will be used as content of the generated FlowFiles and the File Size will be ignored. Finally, if Expression Language is used, evaluation will be performed only once per batch of generated FlowFiles **Supports Expression Language: true** |
+| **Data Format** | Binary | Text Binary | Specifies whether the data should be Text or Binary |
+| **File Size** | 1 kB | | The size of the file that will be used **Supports Expression Language: true** |
+| **Unique FlowFiles** | true | true false | If true, each FlowFile that is generated will be unique. If false, a random value will be generated and all FlowFiles will get the same content but this offers much higher throughput (but see the description of Custom Text for special non-random use cases) |
+
+### Relationships
+
+| Name | Description |
+|---------|-------------|
+| success | success |
+
+
+## GetFileRs
+
+### Description
+
+RUST TEST PROCESSOR: Creates FlowFiles from files in a directory. MiNiFi will ignore files for which it doesn't have read permissions.
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|-------------------------|---------------|------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Batch Size** | 10 | | The maximum number of files to pull in each iteration |
+| **Ignore Hidden Files** | true | true false | Indicates whether or not hidden files should be ignored |
+| **Input Directory** | | | The input directory from which to pull files **Supports Expression Language: true** |
+| Keep Source File | false | true false | If true, the file is not deleted after it has been copied to the Content Repository |
+| Maximum File Age | | | The maximum age that a file must be in order to be pulled; any file older than this amount of time (according to last modification date) will be ignored |
+| Maximum File Size | | | The maximum size that a file can be in order to be pulled |
+| Minimum File Age | | | The minimum age that a file must be in order to be pulled; any file younger than this amount of time (according to last modification date) will be ignored |
+| Minimum File Size | | | The minimum size that a file can be in order to be pulled |
+| Polling Interval | | | Indicates how long to wait before performing a directory listing |
+| Recurse Subdirectories | true | true false | Indicates whether or not to pull files from subdirectories |
+
+### Relationships
+
+| Name | Description |
+|---------|----------------------------------------------|
+| success | FlowFiles are transferred here after logging |
+
+### Output Attributes
+
+| Attribute | Relationship | Description |
+|---------------|--------------|-------------------------------------------------------------------------------------------------------------------------------------|
+| absolute.path | success | The full/absolute path from where a file was picked up. The current 'path' attribute is still populated, but may be a relative path |
+| filename | success | The filename is set to the name of the file on disk |
+
+
+## KamikazeProcessorRs
+
+### Description
+
+RUST TEST PROCESSOR: This processor can fail or panic in on_trigger and on_schedule calls based on configuration. Only for testing purposes.
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|------------------------|---------------|-----------------------------------------------------------------------------------------------|------------------------------------------|
+| **Schedule Behaviour** | ReturnOk | ReturnErr ReturnOk GetNotRegisteredProperty GetInvalidControllerService Panic | What to do during the on_schedule method |
+| **Trigger Behaviour** | ReturnOk | ReturnErr ReturnOk GetNotRegisteredProperty GetInvalidControllerService Panic | What to do during the trigger method |
+
+### Relationships
+
+| Name | Description |
+|---------|----------------------|
+| success | success relationship |
+
+
+## LogAttributeRs
+
+### Description
+
+RUST TEST PROCESSOR: Logs attributes of flow files in the MiNiFi application log.
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|-----------------------|---------------|------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Attributes to Ignore | | | A comma-separated list of Attributes to ignore. If not specified, no attributes will be ignored. |
+| Attributes to Log | | | A comma-separated list of Attributes to Log. If not specified, all attributes will be logged. |
+| **FlowFiles To Log** | 1 | | Number of flow files to log. If set to zero all flow files will be logged. Please note that this may block other threads from running if not used judiciously. |
+| **Hexencode Payload** | false | true false | If true, the FlowFile's payload will be logged in a hexencoded format |
+| **Log Level** | Info | Trace Debug Info Warn Error Critical Off | The Log Level to use when logging the Attributes |
+| **Log Payload** | false | true false | If true, the FlowFile's payload will be logged, in addition to its attributes. Otherwise, just the Attributes will be logged. |
+| Log Prefix | | | Log prefix appended to the log lines. It helps to distinguish the output of multiple LogAttribute processors. |
+
+### Relationships
+
+| Name | Description |
+|---------|----------------------------------------------|
+| success | FlowFiles are transferred here after logging |
+
+
+## LoremIpsumCSUser
+
+### Description
+
+RUST TEST PROCESSOR: Processor to test Controller Service API
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|------------------------------------|---------------|-------------------|--------------------------------------------|
+| **Lorem Ipsum Controller Service** | | | Name of the lorem ipsum controller service |
+| **Write Method** | Buffer | Buffer Stream | Which API to test |
+
+### Relationships
+
+| Name | Description |
+|---------|------------------------------|
+| success | All flowfile are routed here |
+
+
+## PutFileRs
+
+### Description
+
+RUST TEST PROCESSOR: Writes the contents of a FlowFile to the local file system.
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|----------------------------------|---------------|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| **Conflict Resolution Strategy** | fail | fail replace ignore | Indicates what should happen when a file with the same name already exists in the output directory |
+| **Create Missing Directories** | true | true false | If true, then missing destination directories will be created. If false, flowfiles are penalized and sent to failure. |
+| **Directory** | . | | The output directory to which to put files **Supports Expression Language: true** |
+| Directory Permissions | | | Sets the permissions on the directories being created if 'Create Missing Directories' property is set. Must be an octal number (e.g. 644 or 0755). Not supported on Windows systems. |
+| Maximum File Count | | | Specifies the maximum number of files that can exist in the output directory |
+| Permissions | | | Sets the permissions on the output file to the value of this attribute. Must be an octal number (e.g. 644 or 0755). Not supported on Windows systems. |
+
+### Relationships
+
+| Name | Description |
+|---------|-----------------------------------------------------------------------------------|
+| failure | Failed files (conflict, write failure, etc.) are transferred to failure |
+| success | Flowfiles that are successfully written to a file are routed to this relationship |
+
+
+## ZooProcessorRs
+
+### Description
+
+RUST TEST PROCESSOR: ZooProcessorRs
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|----------------------------|---------------|------------------|--------------------------|
+| **Can fly service** | | | Test CanFlyService |
+| **Number of Legs service** | | | Test NumberOfLegsService |
+
+### Relationships
+
+| Name | Description |
+|------|-------------|
+
+
+## DogControllerRs
+
+### Description
+
+RUST TEST CONTROLLER SERVICE: DogControllerRs
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|-------------------|---------------|------------------|-------------------------------------------------------|
+| Extra information | | | We need this to verify the casting was done correctly |
+| **Has Jetpack** | false | true false | Whether or not the dog has a jetpack |
+
+
+## DuckControllerRs
+
+### Description
+
+RUST TEST CONTROLLER SERVICE: DuckControllerRs
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|------|---------------|------------------|-------------|
+
+
+## DummyControllerService
+
+### Description
+
+RUST TEST CONTROLLER SERVICE: Dummy Controller Service
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|------|---------------|------------------|-------------|
+
+
+## LoremIpsumControllerService
+
+### Description
+
+RUST TEST CONTROLLER SERVICE: Holds generated lorem ipsum
+
+### Properties
+
+In the list below, the names of required properties appear in bold. Any other properties (not in bold) are considered optional. The table also indicates any default values, and whether a property supports the NiFi Expression Language.
+
+| Name | Default Value | Allowable Values | Description |
+|------------|---------------|------------------|----------------------------|
+| **Length** | 25 | | How many words to generate |
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/controller_services/animal_controller_apis.rs b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/animal_controller_apis.rs
new file mode 100644
index 0000000000..d63c0026c1
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/animal_controller_apis.rs
@@ -0,0 +1,29 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use minifi_native::macros::controller_service_api;
+use std::fmt::Debug;
+
+#[controller_service_api]
+pub trait NumberOfLegsControllerApi: Debug {
+ fn number_of_legs(&self) -> u8;
+}
+
+#[controller_service_api]
+pub trait CanFlyControllerApi: Debug {
+ fn can_fly(&self) -> bool;
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/controller_services/dog_controller_service.rs b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/dog_controller_service.rs
new file mode 100644
index 0000000000..2fa133742e
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/dog_controller_service.rs
@@ -0,0 +1,92 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use crate::controller_services::animal_controller_apis::{
+ CanFlyControllerApi, NumberOfLegsControllerApi,
+};
+use minifi_native::ControllerServiceApi;
+use minifi_native::PropertyConstraints::{NoConstraints, Validator};
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ ControllerServiceDefinition, EnableControllerService, GetProperty, Logger, MinifiError,
+ Property, ProvidedInterface, StandardPropertyValidator, create_provided_interface,
+};
+
+pub(crate) const HAS_JETPACK: Property = Property {
+ name: "Has Jetpack",
+ description: "Whether or not the dog has a jetpack",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some("false"),
+ constraints: Validator(StandardPropertyValidator::BoolValidator),
+};
+
+pub(crate) const EXTRA_INFO: Property = Property {
+ name: "Extra information",
+ description: "We need this to verify the casting was done correctly",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: NoConstraints,
+};
+
+#[allow(dead_code)] // extra_info is only used by {:?}
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct DogControllerRs {
+ has_jetpack: bool,
+ extra_info: String,
+}
+
+impl NumberOfLegsControllerApi for DogControllerRs {
+ fn number_of_legs(&self) -> u8 {
+ 4
+ }
+}
+
+impl CanFlyControllerApi for DogControllerRs {
+ fn can_fly(&self) -> bool {
+ self.has_jetpack
+ }
+}
+
+impl EnableControllerService for DogControllerRs {
+ fn enable(context: &Ctx, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ let has_jetpack = context.get_req_property::(&HAS_JETPACK)?;
+ let extra_info = context
+ .get_property::(&EXTRA_INFO)?
+ .unwrap_or("".into());
+
+ Ok(Self {
+ has_jetpack,
+ extra_info,
+ })
+ }
+}
+
+impl ControllerServiceDefinition for DogControllerRs {
+ const DESCRIPTION: &'static str = "RUST TEST CONTROLLER SERVICE: DogControllerRs";
+ const PROPERTIES: &'static [Property] = &[HAS_JETPACK, EXTRA_INFO];
+ const PROVIDED_APIS: &'static [ProvidedInterface] = &[
+ create_provided_interface!(dyn CanFlyControllerApi),
+ create_provided_interface!(dyn NumberOfLegsControllerApi),
+ ];
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/controller_services/duck_controller_service.rs b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/duck_controller_service.rs
new file mode 100644
index 0000000000..d1d2518a11
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/duck_controller_service.rs
@@ -0,0 +1,59 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use crate::controller_services::animal_controller_apis::{
+ CanFlyControllerApi, NumberOfLegsControllerApi,
+};
+use minifi_native::ControllerServiceApi;
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ ControllerServiceDefinition, EnableControllerService, GetProperty, Logger, MinifiError,
+ Property, ProvidedInterface, create_provided_interface,
+};
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct DuckControllerRs {}
+
+impl NumberOfLegsControllerApi for DuckControllerRs {
+ fn number_of_legs(&self) -> u8 {
+ 2
+ }
+}
+
+impl CanFlyControllerApi for DuckControllerRs {
+ fn can_fly(&self) -> bool {
+ true
+ }
+}
+
+impl EnableControllerService for DuckControllerRs {
+ fn enable(_context: &Ctx, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ Ok(Self {})
+ }
+}
+
+impl ControllerServiceDefinition for DuckControllerRs {
+ const DESCRIPTION: &'static str = "RUST TEST CONTROLLER SERVICE: DuckControllerRs";
+ const PROPERTIES: &'static [Property] = &[];
+ const PROVIDED_APIS: &'static [ProvidedInterface] = &[
+ create_provided_interface!(dyn CanFlyControllerApi),
+ create_provided_interface!(dyn NumberOfLegsControllerApi),
+ ];
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/controller_services/dummy_controller_service.rs b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/dummy_controller_service.rs
new file mode 100644
index 0000000000..2b593b9aff
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/dummy_controller_service.rs
@@ -0,0 +1,40 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ ControllerServiceDefinition, EnableControllerService, GetProperty, Logger, MinifiError,
+ Property, ProvidedInterface,
+};
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct DummyControllerService {}
+
+impl EnableControllerService for DummyControllerService {
+ fn enable(_context: &Ctx, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ Ok(Self {})
+ }
+}
+
+impl ControllerServiceDefinition for DummyControllerService {
+ const DESCRIPTION: &'static str = "RUST TEST CONTROLLER SERVICE: Dummy Controller Service";
+ const PROPERTIES: &'static [Property] = &[];
+ const PROVIDED_APIS: &'static [ProvidedInterface] = &[];
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/controller_services/lorem_ipsum_controller_service.rs b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/lorem_ipsum_controller_service.rs
new file mode 100644
index 0000000000..7c692ba396
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/lorem_ipsum_controller_service.rs
@@ -0,0 +1,55 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use lipsum::lipsum;
+use minifi_native::PropertyConstraints::Validator;
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ ControllerServiceDefinition, EnableControllerService, GetProperty, Logger, MinifiError,
+ Property, ProvidedInterface, StandardPropertyValidator,
+};
+
+const LENGTH: Property = Property {
+ name: "Length",
+ description: "How many words to generate",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some("25"),
+ constraints: Validator(StandardPropertyValidator::U64Validator),
+};
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct LoremIpsumControllerService {
+ pub data: String,
+}
+
+impl EnableControllerService for LoremIpsumControllerService {
+ fn enable(context: &P, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ let data = lipsum(context.get_req_property::(&LENGTH)?);
+ Ok(Self { data })
+ }
+}
+
+impl ControllerServiceDefinition for LoremIpsumControllerService {
+ const DESCRIPTION: &'static str = "RUST TEST CONTROLLER SERVICE: Holds generated lorem ipsum";
+ const PROPERTIES: &'static [Property] = &[LENGTH];
+ const PROVIDED_APIS: &'static [ProvidedInterface] = &[];
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/controller_services/mod.rs b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/mod.rs
new file mode 100644
index 0000000000..c971d5df3c
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/controller_services/mod.rs
@@ -0,0 +1,22 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+pub(crate) mod animal_controller_apis;
+pub(crate) mod dog_controller_service;
+pub(crate) mod duck_controller_service;
+pub(crate) mod dummy_controller_service;
+pub(crate) mod lorem_ipsum_controller_service;
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/lib.rs b/minifi_rust/extensions/minifi_rs_playground/src/lib.rs
new file mode 100644
index 0000000000..de8aaad435
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/lib.rs
@@ -0,0 +1,60 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+mod controller_services;
+mod processors;
+
+use crate::controller_services::dog_controller_service::DogControllerRs;
+use crate::controller_services::duck_controller_service::DuckControllerRs;
+use crate::controller_services::dummy_controller_service::DummyControllerService;
+use crate::controller_services::lorem_ipsum_controller_service::LoremIpsumControllerService;
+use crate::processors::asciify_german::AsciifyGerman;
+use crate::processors::count_actual_logging::CountActualLogging;
+use crate::processors::duplicate_text::DuplicateStreamText;
+use crate::processors::generate_flow_file::GenerateFlowFileRs;
+use crate::processors::get_file::GetFileRs;
+use crate::processors::kamikaze_processor::KamikazeProcessorRs;
+use crate::processors::log_attribute::LogAttributeRs;
+use crate::processors::lorem_ipsum_cs_user::LoremIpsumCSUser;
+use crate::processors::put_file::PutFileRs;
+use crate::processors::zoo_processor::ZooProcessorRs;
+
+use minifi_native::{
+ ComplexProcessorType, FlowFileSourceProcessorType, FlowFileStreamTransformProcessorType,
+ FlowFileTransformProcessorType, MultiThreaded, SingleThreaded,
+};
+
+minifi_native::declare_minifi_extension!(
+ processors: [
+ (ComplexProcessorType, MultiThreaded, GenerateFlowFileRs),
+ (ComplexProcessorType, MultiThreaded, LogAttributeRs),
+ (ComplexProcessorType, MultiThreaded, GetFileRs),
+ (ComplexProcessorType, MultiThreaded, KamikazeProcessorRs),
+ (ComplexProcessorType, SingleThreaded, CountActualLogging),
+ (FlowFileSourceProcessorType, MultiThreaded, LoremIpsumCSUser),
+ (FlowFileTransformProcessorType, MultiThreaded, PutFileRs),
+ (FlowFileStreamTransformProcessorType, MultiThreaded, AsciifyGerman),
+ (FlowFileStreamTransformProcessorType, SingleThreaded, DuplicateStreamText),
+ (ComplexProcessorType, MultiThreaded, ZooProcessorRs),
+ ],
+ controllers: [
+ LoremIpsumControllerService,
+ DummyControllerService,
+ DogControllerRs,
+ DuckControllerRs
+ ]
+);
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german.rs
new file mode 100644
index 0000000000..a4d8f68ecc
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german.rs
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+// This processor is used to test streaming flow file transforms, with changing FlowFile sizes
+
+use crate::processors::asciify_german::relationships::FAILURE;
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ FlowFileStreamTransform, GetProperty, InputStream, Logger, MinifiError, OutputStream, Schedule,
+ TransformStreamResult,
+};
+use std::collections::HashMap;
+
+mod relationships;
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct AsciifyGerman {}
+
+impl Schedule for AsciifyGerman {
+ fn schedule(_context: &P, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ Ok(Self {})
+ }
+}
+
+impl FlowFileStreamTransform for AsciifyGerman {
+ fn transform(
+ &self,
+ _context: &Ctx,
+ input_stream: &mut dyn InputStream,
+ output_stream: &mut dyn OutputStream,
+ _logger: &LoggerImpl,
+ ) -> Result {
+ let mut byte = [0u8; 1];
+
+ while input_stream.read(&mut byte)? > 0 {
+ match byte[0] {
+ 0..=127 => {
+ output_stream.write_all(&byte)?;
+ }
+ 0xC3 => {
+ let mut next = [0u8; 1];
+ if input_stream.read(&mut next)? == 0 {
+ // Truncated multi-byte sequence at EOF — treat as malformed input.
+ return Ok(TransformStreamResult::route_without_changes(&FAILURE));
+ }
+ match next[0] {
+ 0xA4 => output_stream.write_all(b"ae")?, // ä
+ 0xB6 => output_stream.write_all(b"oe")?, // ö
+ 0xBC => output_stream.write_all(b"ue")?, // ü
+ 0x84 => output_stream.write_all(b"Ae")?, // Ä
+ 0x96 => output_stream.write_all(b"Oe")?, // Ö
+ 0x9C => output_stream.write_all(b"Ue")?, // Ü
+ 0x9F => output_stream.write_all(b"ss")?, // ß
+ _ => return Ok(TransformStreamResult::route_without_changes(&FAILURE)),
+ }
+ }
+ _ => return Ok(TransformStreamResult::route_without_changes(&FAILURE)),
+ }
+ }
+
+ output_stream.flush()?;
+ Ok(TransformStreamResult::new(
+ &relationships::SUCCESS,
+ HashMap::new(),
+ ))
+ }
+}
+
+mod processor_definition;
+#[cfg(test)]
+mod tests;
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german/processor_definition.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german/processor_definition.rs
new file mode 100644
index 0000000000..eb3f359d83
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german/processor_definition.rs
@@ -0,0 +1,32 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use crate::processors::asciify_german::AsciifyGerman;
+use minifi_native::{
+ OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, Property, Relationship,
+};
+
+impl ProcessorDefinition for AsciifyGerman {
+ const DESCRIPTION: &'static str = "RUST TEST PROCESSOR: This processor switches German characters with their ascii counterparts. (to test stream API)";
+ const INPUT_REQUIREMENT: ProcessorInputRequirement = ProcessorInputRequirement::Required;
+ const SUPPORTS_DYNAMIC_PROPERTIES: bool = false;
+ const SUPPORTS_DYNAMIC_RELATIONSHIPS: bool = false;
+ const OUTPUT_ATTRIBUTES: &'static [OutputAttribute] = &[];
+ const RELATIONSHIPS: &'static [Relationship] =
+ &[super::relationships::SUCCESS, super::relationships::FAILURE];
+ const PROPERTIES: &'static [Property] = &[];
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german/relationships.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german/relationships.rs
new file mode 100644
index 0000000000..e624aa3bc2
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german/relationships.rs
@@ -0,0 +1,28 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use minifi_native::Relationship;
+
+pub(crate) const SUCCESS: Relationship = Relationship {
+ name: "success",
+ description: "All asciified flowfiles are routed here",
+};
+
+pub(crate) const FAILURE: Relationship = Relationship {
+ name: "failure",
+ description: "Non-german flowfiles are routed here",
+};
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german/tests.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german/tests.rs
new file mode 100644
index 0000000000..c9b93c6581
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/asciify_german/tests.rs
@@ -0,0 +1,91 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use super::*;
+use crate::processors::asciify_german::relationships::SUCCESS;
+use minifi_native::{IoState, MockLogger, MockProcessContext};
+use std::io::BufReader;
+
+#[test]
+fn schedule_succeeds_with_default_values() {
+ assert!(AsciifyGerman::schedule(&MockProcessContext::new(), &MockLogger::new()).is_ok());
+}
+
+#[test]
+fn simple_test() {
+ let process_context = MockProcessContext::new();
+ let context = MockProcessContext::new();
+ let logger = MockLogger::new();
+
+ let asciify_german =
+ AsciifyGerman::schedule(&process_context, &logger).expect("Should succeed");
+ let input_str = "Falsches Üben von Xylophonmusik quält jeden größeren Zwerg.";
+ let mut input_stream = BufReader::new(input_str.as_bytes());
+ let mut output_vec: Vec = Vec::new();
+ {
+ let result = asciify_german
+ .transform(&context, &mut input_stream, &mut output_vec, &logger)
+ .expect("Should succeed");
+ assert_eq!(result.write_status(), IoState::Ok);
+ assert_eq!(result.target_relationship_name(), SUCCESS.name);
+ }
+ assert_eq!(
+ output_vec,
+ "Falsches Ueben von Xylophonmusik quaelt jeden groesseren Zwerg.".as_bytes()
+ );
+}
+
+#[test]
+fn simple_failure_test() {
+ let process_context = MockProcessContext::new();
+ let context = MockProcessContext::new();
+ let logger = MockLogger::new();
+
+ let asciify_german =
+ AsciifyGerman::schedule(&process_context, &logger).expect("Should succeed");
+ let input_str = "Üldögélő műújságíró";
+ let mut input_stream = BufReader::new(input_str.as_bytes());
+ let mut output_vec: Vec = Vec::new();
+ {
+ let result = asciify_german
+ .transform(&context, &mut input_stream, &mut output_vec, &logger)
+ .expect("Should succeed");
+ assert_eq!(result.write_status(), IoState::Cancel);
+ assert_eq!(result.target_relationship_name(), FAILURE.name);
+ }
+ assert_eq!(output_vec, "Ueldoeg".as_bytes());
+}
+
+#[test]
+fn truncated_umlaut_at_eof_routes_to_failure() {
+ let context = MockProcessContext::new();
+ let logger = MockLogger::new();
+
+ let asciify_german = AsciifyGerman::schedule(&context, &logger).expect("Should succeed");
+ // A lone 0xC3 is the leading byte of every German umlaut in UTF-8; on EOF the
+ // sequence is incomplete and the processor must report failure rather than
+ // silently truncating.
+ let input_bytes: &[u8] = &[b'a', 0xC3];
+ let mut input_stream = BufReader::new(input_bytes);
+ let mut output_vec: Vec = Vec::new();
+
+ let result = asciify_german
+ .transform(&context, &mut input_stream, &mut output_vec, &logger)
+ .expect("Should succeed");
+ assert_eq!(result.write_status(), IoState::Cancel);
+ assert_eq!(result.target_relationship_name(), FAILURE.name);
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/count_actual_logging.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/count_actual_logging.rs
new file mode 100644
index 0000000000..95b25910cc
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/count_actual_logging.rs
@@ -0,0 +1,76 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+// Test processor to test lazy logging
+
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ GetProperty, Logger, MinifiError, MutTrigger, OnTriggerResult, OutputAttribute, ProcessContext,
+ ProcessSession, ProcessorDefinition, ProcessorInputRequirement, Property, Relationship,
+ Schedule, debug, info, trace,
+};
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct CountActualLogging {
+ log_count: usize,
+}
+
+impl CountActualLogging {
+ fn get_incremented_log_count(&mut self) -> usize {
+ self.log_count += 1;
+ self.log_count
+ }
+}
+
+impl Schedule for CountActualLogging {
+ fn schedule(_context: &P, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ Ok(Self { log_count: 0 })
+ }
+}
+
+impl MutTrigger for CountActualLogging {
+ fn trigger(
+ &mut self,
+ _context: &mut PC,
+ _session: &mut PS,
+ logger: &L,
+ ) -> Result
+ where
+ PC: ProcessContext,
+ PS: ProcessSession,
+ L: Logger,
+ {
+ trace!(logger, "trace {}", self.get_incremented_log_count());
+ debug!(logger, "debug {}", self.get_incremented_log_count());
+ info!(logger, "info {}", self.get_incremented_log_count());
+
+ Ok(OnTriggerResult::Ok)
+ }
+}
+
+impl ProcessorDefinition for CountActualLogging {
+ const DESCRIPTION: &'static str = "RUST TEST PROCESSOR: For testing lazy logging";
+ const INPUT_REQUIREMENT: ProcessorInputRequirement = ProcessorInputRequirement::Forbidden;
+ const SUPPORTS_DYNAMIC_PROPERTIES: bool = false;
+ const SUPPORTS_DYNAMIC_RELATIONSHIPS: bool = false;
+ const OUTPUT_ATTRIBUTES: &'static [OutputAttribute] = &[];
+ const RELATIONSHIPS: &'static [Relationship] = &[];
+ const PROPERTIES: &'static [Property] = &[];
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/duplicate_text.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/duplicate_text.rs
new file mode 100644
index 0000000000..8d388cb204
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/duplicate_text.rs
@@ -0,0 +1,71 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ GetAttribute, GetControllerService, GetProperty, InputStream, Logger, MinifiError,
+ MutFlowFileStreamTransform, OutputAttribute, OutputStream, ProcessorDefinition,
+ ProcessorInputRequirement, Property, Relationship, Schedule, TransformStreamResult,
+};
+use std::collections::HashMap;
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct DuplicateStreamText {}
+
+pub(crate) const SUCCESS: Relationship = Relationship {
+ name: "success",
+ description: "",
+};
+
+impl Schedule for DuplicateStreamText {
+ fn schedule(
+ _context: &Ctx,
+ _logger: &L,
+ ) -> Result
+ where
+ Self: Sized,
+ {
+ Ok(Self {})
+ }
+}
+
+impl MutFlowFileStreamTransform for DuplicateStreamText {
+ fn transform(
+ &mut self,
+ _context: &Ctx,
+ input_stream: &mut dyn InputStream,
+ output_stream: &mut dyn OutputStream,
+ _logger: &LoggerImpl,
+ ) -> Result {
+ let mut byte = [0u8; 1];
+ while input_stream.read(&mut byte)? > 0 {
+ let _ = output_stream.write(&byte)?;
+ let _ = output_stream.write(&byte)?;
+ }
+ Ok(TransformStreamResult::new(&SUCCESS, HashMap::new()))
+ }
+}
+
+impl ProcessorDefinition for DuplicateStreamText {
+ const DESCRIPTION: &'static str = "RUST TEST PROCESSOR: Duplicate text";
+ const INPUT_REQUIREMENT: ProcessorInputRequirement = ProcessorInputRequirement::Required;
+ const SUPPORTS_DYNAMIC_PROPERTIES: bool = false;
+ const SUPPORTS_DYNAMIC_RELATIONSHIPS: bool = false;
+ const OUTPUT_ATTRIBUTES: &'static [OutputAttribute] = &[];
+ const RELATIONSHIPS: &'static [Relationship] = &[SUCCESS];
+ const PROPERTIES: &'static [Property] = &[];
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file.rs
new file mode 100644
index 0000000000..4464ef4e77
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file.rs
@@ -0,0 +1,190 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+// This is the (not production ready) reimplementation of the already existing standard GenerateFlowFile processor
+
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ DataSize, GetProperty, Logger, MinifiError, OnTriggerResult, ProcessContext, ProcessSession,
+ Schedule, Trigger,
+};
+use rand::RngExt;
+use rand::distr::Alphanumeric;
+use std::cmp::PartialEq;
+
+mod properties;
+mod relationships;
+
+#[derive(Debug, PartialEq)]
+enum Mode {
+ UniqueBytes,
+ UniqueText,
+ NotUniqueBytes,
+ NotUniqueText,
+ CustomText,
+ Empty,
+}
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct GenerateFlowFileRs {
+ mode: Mode,
+ batch_size: u64,
+ file_size: u64,
+ data_generated_during_on_schedule: Vec,
+}
+
+impl Schedule for GenerateFlowFileRs {
+ fn schedule(context: &P, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ let is_unique = context.get_req_property::(&properties::UNIQUE_FLOW_FILES)?;
+ let is_text = context.get_req_property::(&properties::DATA_FORMAT)? == "Text";
+ let has_custom_text = context
+ .get_property::(&properties::CUSTOM_TEXT)?
+ .is_some();
+
+ let file_size = context.get_req_property::(&properties::FILE_SIZE)?;
+ let batch_size = context.get_req_property::(&properties::BATCH_SIZE)?;
+
+ let mode = Self::get_mode(is_unique, is_text, has_custom_text, file_size);
+ let data_generated_during_on_schedule =
+ if mode == Mode::NotUniqueText || mode == Mode::NotUniqueBytes {
+ let mut data = vec![0; file_size as usize];
+ Self::generate_data(&mut data, is_text);
+ data
+ } else {
+ vec![]
+ };
+
+ Ok(Self {
+ mode,
+ batch_size,
+ file_size,
+ data_generated_during_on_schedule,
+ })
+ }
+}
+
+impl GenerateFlowFileRs {
+ fn is_unique(&self) -> bool {
+ match self.mode {
+ Mode::UniqueBytes => true,
+ Mode::UniqueText => true,
+ Mode::NotUniqueBytes => false,
+ Mode::NotUniqueText => false,
+ Mode::CustomText => false,
+ Mode::Empty => false,
+ }
+ }
+
+ fn is_text(&self) -> bool {
+ match self.mode {
+ Mode::UniqueBytes => false,
+ Mode::UniqueText => true,
+ Mode::NotUniqueBytes => false,
+ Mode::NotUniqueText => true,
+ Mode::CustomText => true,
+ Mode::Empty => false,
+ }
+ }
+
+ fn get_mode(is_unique: bool, is_text: bool, has_custom_text: bool, file_size: u64) -> Mode {
+ if is_text && !is_unique && has_custom_text {
+ return Mode::CustomText;
+ }
+
+ if file_size == 0 {
+ return Mode::Empty;
+ }
+
+ match (is_unique, is_text) {
+ (true, true) => Mode::UniqueText,
+ (true, false) => Mode::UniqueBytes,
+ (false, true) => Mode::NotUniqueText,
+ (false, false) => Mode::NotUniqueBytes,
+ }
+ }
+
+ fn generate_data(data: &mut [u8], text_data: bool) {
+ let mut rng = rand::rng();
+
+ if text_data {
+ for byte in data.iter_mut() {
+ *byte = rng.sample(Alphanumeric);
+ }
+ } else {
+ rng.fill(data);
+ }
+ }
+}
+
+impl Trigger for GenerateFlowFileRs {
+ fn trigger(
+ &self,
+ context: &mut PC,
+ session: &mut PS,
+ _logger: &L,
+ ) -> Result
+ where
+ PC: ProcessContext,
+ PS: ProcessSession,
+ {
+ let non_unique_data_buffer: &[u8];
+ let custom_text_for_batch: Option;
+
+ if self.mode == Mode::CustomText {
+ // CustomText mode must have the Custom Text property set at
+ // trigger time — falling back to `data_generated_during_on_schedule`
+ // (which is empty for this mode) would silently produce empty
+ // flow files.
+ custom_text_for_batch = Some(
+ context
+ .get_property(&properties::CUSTOM_TEXT, None)?
+ .ok_or_else(|| {
+ MinifiError::trigger_err(
+ "GenerateFlowFile is in CustomText mode but the \"Custom Text\" \
+ property is not set at trigger time",
+ )
+ })?,
+ );
+ non_unique_data_buffer = custom_text_for_batch.as_ref().unwrap().as_bytes();
+ } else {
+ non_unique_data_buffer = self.data_generated_during_on_schedule.as_slice();
+ }
+
+ for _ in 0..self.batch_size {
+ let ff = session.create()?;
+ if self.mode != Mode::Empty {
+ if self.is_unique() {
+ let mut unique_data: Vec = vec![0; self.file_size as usize];
+ Self::generate_data(&mut unique_data, self.is_text());
+ session.write(&ff, unique_data.as_slice())?;
+ } else {
+ session.write(&ff, non_unique_data_buffer)?;
+ }
+ }
+ session.transfer(ff, relationships::SUCCESS.name)?;
+ }
+ Ok(OnTriggerResult::Ok)
+ }
+}
+
+pub(crate) mod processor_definition;
+
+#[cfg(test)]
+mod tests;
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/processor_definition.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/processor_definition.rs
new file mode 100644
index 0000000000..1f75416e4d
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/processor_definition.rs
@@ -0,0 +1,38 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use super::properties::*;
+use super::{GenerateFlowFileRs, relationships};
+use minifi_native::{
+ OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, Property, Relationship,
+};
+
+impl ProcessorDefinition for GenerateFlowFileRs {
+ const DESCRIPTION: &'static str = "RUST TEST PROCESSOR: This processor creates FlowFiles with random data or custom content. GenerateFlowFile is useful for load testing, configuration, and simulation.";
+ const INPUT_REQUIREMENT: ProcessorInputRequirement = ProcessorInputRequirement::Forbidden;
+ const SUPPORTS_DYNAMIC_PROPERTIES: bool = false;
+ const SUPPORTS_DYNAMIC_RELATIONSHIPS: bool = false;
+ const OUTPUT_ATTRIBUTES: &'static [OutputAttribute] = &[];
+ const RELATIONSHIPS: &'static [Relationship] = &[relationships::SUCCESS];
+ const PROPERTIES: &'static [Property] = &[
+ FILE_SIZE,
+ BATCH_SIZE,
+ DATA_FORMAT,
+ UNIQUE_FLOW_FILES,
+ CUSTOM_TEXT,
+ ];
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/properties.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/properties.rs
new file mode 100644
index 0000000000..468b76f7cf
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/properties.rs
@@ -0,0 +1,69 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use minifi_native::PropertyConstraints::{AllowedValues, NoConstraints, Validator};
+use minifi_native::{Property, StandardPropertyValidator};
+
+pub(crate) const FILE_SIZE: Property = Property {
+ name: "File Size",
+ description: "The size of the file that will be used",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: true,
+ default_value: Some("1 kB"),
+ constraints: Validator(StandardPropertyValidator::DataSizeValidator),
+};
+
+pub(crate) const BATCH_SIZE: Property = Property {
+ name: "Batch Size",
+ description: "The number of FlowFiles to be transferred in each invocation",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some("1"),
+ constraints: Validator(StandardPropertyValidator::U64Validator),
+};
+
+pub(crate) const DATA_FORMAT: Property = Property {
+ name: "Data Format",
+ description: "Specifies whether the data should be Text or Binary",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some("Binary"),
+ constraints: AllowedValues(&["Text", "Binary"]),
+};
+
+pub(crate) const UNIQUE_FLOW_FILES: Property = Property {
+ name: "Unique FlowFiles",
+ description: "If true, each FlowFile that is generated will be unique. If false, a random value will be generated and all FlowFiles will get the same content but this offers much higher throughput (but see the description of Custom Text for special non-random use cases)",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some("true"),
+ constraints: Validator(StandardPropertyValidator::BoolValidator),
+};
+
+pub(crate) const CUSTOM_TEXT: Property = Property {
+ name: "Custom Text",
+ description: "If Data Format is text and if Unique FlowFiles is false, then this custom text will be used as content of the generated FlowFiles and the File Size will be ignored. Finally, if Expression Language is used, evaluation will be performed only once per batch of generated FlowFiles",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: true,
+ default_value: None,
+ constraints: NoConstraints,
+};
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/relationships.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/relationships.rs
new file mode 100644
index 0000000000..81fd6f9040
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/relationships.rs
@@ -0,0 +1,23 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use minifi_native::Relationship;
+
+pub(crate) const SUCCESS: Relationship = Relationship {
+ name: "success",
+ description: "success",
+};
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/tests.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/tests.rs
new file mode 100644
index 0000000000..39b2facd47
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/generate_flow_file/tests.rs
@@ -0,0 +1,155 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use super::*;
+use crate::processors::generate_flow_file::properties::{
+ BATCH_SIZE, CUSTOM_TEXT, DATA_FORMAT, UNIQUE_FLOW_FILES,
+};
+use minifi_native::{MockLogger, MockProcessContext, MockProcessSession};
+
+#[test]
+fn schedule_succeeds_with_default_values() {
+ assert!(GenerateFlowFileRs::schedule(&MockProcessContext::new(), &MockLogger::new()).is_ok());
+}
+
+#[test]
+fn generate_flow_file_empty_test() {
+ let logger = MockLogger::new();
+ let mut context = MockProcessContext::new();
+ context
+ .properties
+ .insert(properties::FILE_SIZE.name.to_string(), "0".to_string());
+ context
+ .properties
+ .insert(UNIQUE_FLOW_FILES.name.to_string(), "false".to_string());
+ context
+ .properties
+ .insert(DATA_FORMAT.name.to_string(), "Text".to_string());
+
+ let processor = GenerateFlowFileRs::schedule(&context, &logger).unwrap();
+ let mut session = MockProcessSession::new();
+ assert_eq!(
+ processor
+ .trigger(&mut context, &mut session, &logger)
+ .unwrap(),
+ OnTriggerResult::Ok
+ );
+ let result_flow_files = session.transferred_flow_files.borrow();
+ assert_eq!(result_flow_files.len(), 1);
+ assert_eq!(result_flow_files[0].flow_file.content_len(), 0);
+}
+
+#[test]
+fn generate_custom_text() {
+ let mut context = MockProcessContext::new();
+ context
+ .properties
+ .insert(properties::FILE_SIZE.name.to_string(), "0".to_string());
+ context
+ .properties
+ .insert(UNIQUE_FLOW_FILES.name.to_string(), "false".to_string());
+ context
+ .properties
+ .insert(DATA_FORMAT.name.to_string(), "Text".to_string());
+ context
+ .properties
+ .insert(CUSTOM_TEXT.name.to_string(), "foo bar baz".to_string());
+
+ let logger = MockLogger::new();
+ let processor = GenerateFlowFileRs::schedule(&context, &logger).unwrap();
+
+ let mut session = MockProcessSession::new();
+ assert_eq!(
+ processor
+ .trigger(&mut context, &mut session, &logger)
+ .expect("Should trigger successfully"),
+ OnTriggerResult::Ok
+ );
+ let result_flow_files = session.transferred_flow_files.borrow();
+ assert_eq!(result_flow_files.len(), 1);
+ assert!(result_flow_files[0].flow_file.content_eq("foo bar baz"),);
+}
+
+#[test]
+fn random_bytes_unique() {
+ let mut context = MockProcessContext::new();
+ context
+ .properties
+ .insert(properties::FILE_SIZE.name.to_string(), "40 B".to_string());
+ context
+ .properties
+ .insert(UNIQUE_FLOW_FILES.name.to_string(), "true".to_string());
+ context
+ .properties
+ .insert(DATA_FORMAT.name.to_string(), "Bytes".to_string());
+ context
+ .properties
+ .insert(BATCH_SIZE.name.to_string(), "2".to_string());
+
+ let logger = MockLogger::new();
+ let processor = GenerateFlowFileRs::schedule(&context, &logger).unwrap();
+ let mut session = MockProcessSession::new();
+ assert_eq!(
+ processor
+ .trigger(&mut context, &mut session, &logger)
+ .expect("Should trigger successfully"),
+ OnTriggerResult::Ok
+ );
+ let result_flow_files = session.transferred_flow_files.borrow();
+ assert_eq!(result_flow_files.len(), 2);
+ assert_eq!(result_flow_files[0].flow_file.content_len(), 40);
+ assert_eq!(result_flow_files[1].flow_file.content_len(), 40);
+ assert_ne!(
+ *result_flow_files[0].flow_file.content.borrow(),
+ *result_flow_files[1].flow_file.content.borrow()
+ );
+}
+
+#[test]
+fn random_bytes_non_unique() {
+ let mut context = MockProcessContext::new();
+ context
+ .properties
+ .insert(properties::FILE_SIZE.name.to_string(), "40 B".to_string());
+ context
+ .properties
+ .insert(UNIQUE_FLOW_FILES.name.to_string(), "false".to_string());
+ context
+ .properties
+ .insert(DATA_FORMAT.name.to_string(), "Bytes".to_string());
+ context
+ .properties
+ .insert(BATCH_SIZE.name.to_string(), "2".to_string());
+
+ let logger = MockLogger::new();
+ let processor = GenerateFlowFileRs::schedule(&context, &logger).unwrap();
+ let mut session = MockProcessSession::new();
+ assert_eq!(
+ processor
+ .trigger(&mut context, &mut session, &logger)
+ .expect("Should trigger successfully"),
+ OnTriggerResult::Ok
+ );
+ let result_flow_files = session.transferred_flow_files.borrow();
+ assert_eq!(result_flow_files.len(), 2);
+ assert_eq!(result_flow_files[0].flow_file.content_len(), 40);
+ assert_eq!(result_flow_files[1].flow_file.content_len(), 40);
+ assert_eq!(
+ *result_flow_files[0].flow_file.content.borrow(),
+ *result_flow_files[1].flow_file.content.borrow()
+ );
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file.rs
new file mode 100644
index 0000000000..dc365e9c40
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file.rs
@@ -0,0 +1,318 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+// This is the (not production ready) reimplementation of the already existing standard GetFile processor
+
+use crate::processors::get_file::output_attributes::{
+ ABSOLUTE_PATH_OUTPUT_ATTRIBUTE, FILENAME_OUTPUT_ATTRIBUTE,
+};
+use crate::processors::get_file::properties::{
+ BATCH_SIZE, DIRECTORY, IGNORE_HIDDEN_FILES, KEEP_SOURCE_FILE, MAX_AGE, MAX_SIZE, MIN_AGE,
+ MIN_SIZE, RECURSE,
+};
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ DataSize, GetProperty, IoState, Logger, MinifiError, OnTriggerResult, ProcessContext,
+ ProcessSession, Schedule, Trigger, debug, info, trace, warn,
+};
+use std::collections::VecDeque;
+use std::error;
+use std::fs::File;
+use std::path::{Path, PathBuf};
+use std::sync::Mutex;
+use std::time::{Duration, Instant, SystemTime};
+use walkdir::{DirEntry, WalkDir};
+
+mod properties;
+mod relationships;
+
+#[derive(Debug)]
+struct GetFileMetrics {
+ accepted_files: u32,
+ input_bytes: u64,
+}
+
+#[derive(Debug)]
+struct DirectoryListing {
+ paths: VecDeque,
+ last_polling_time: Option,
+}
+
+impl DirectoryListing {
+ fn new() -> Self {
+ Self {
+ paths: VecDeque::new(),
+ last_polling_time: None,
+ }
+ }
+}
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct GetFileRs {
+ recursive: bool,
+ keep_source_file: bool,
+ input_directory: PathBuf,
+ poll_interval: Option,
+ directory_listing: Mutex,
+ batch_size: u64,
+ min_size: Option,
+ max_size: Option,
+ min_age: Option,
+ max_age: Option,
+ ignore_hidden_files: bool,
+ metrics: Mutex,
+}
+
+impl GetFileRs {
+ fn is_listing_empty(&self) -> bool {
+ let directory_listing = self.directory_listing.lock().unwrap();
+ directory_listing.paths.is_empty()
+ }
+
+ fn poll_listing(&self, batch_size: u64) -> VecDeque {
+ let mut directory_listings = self.directory_listing.lock().unwrap();
+
+ let mut res = VecDeque::new();
+ for _ in 0..batch_size {
+ if let Some(path) = directory_listings.paths.pop_back() {
+ res.push_back(path);
+ } else {
+ break;
+ }
+ }
+
+ res
+ }
+
+ fn should_poll(&self) -> bool {
+ if self.poll_interval.is_none() {
+ return true;
+ }
+ let directory_listings = self.directory_listing.lock().unwrap();
+
+ if directory_listings.last_polling_time.is_none() {
+ return true;
+ }
+ Instant::now() - directory_listings.last_polling_time.unwrap() > self.poll_interval.unwrap()
+ }
+
+ fn perform_listing(&self) {
+ let mut walker = WalkDir::new(&self.input_directory);
+ if !self.recursive {
+ walker = walker.max_depth(1);
+ }
+
+ let mut new_paths: VecDeque = VecDeque::new();
+ let mut files_added = 0u32;
+ let mut bytes_added = 0u64;
+ for entry in walker.into_iter().filter_map(Result::ok) {
+ if self.entry_matches_criteria(&entry).unwrap_or(false) {
+ let file_size = entry.metadata().map(|m| m.len()).unwrap_or(0);
+ new_paths.push_back(entry.into_path());
+ files_added += 1;
+ bytes_added += file_size;
+ }
+ }
+
+ {
+ let mut directory_listings = self.directory_listing.lock().unwrap();
+ directory_listings.paths.extend(new_paths);
+ directory_listings.last_polling_time = Some(Instant::now());
+ }
+
+ let mut metrics = self.metrics.lock().unwrap();
+ metrics.accepted_files += files_added;
+ metrics.input_bytes += bytes_added;
+ }
+
+ fn entry_matches_criteria(&self, dir_entry: &DirEntry) -> Result> {
+ let metadata = dir_entry.metadata()?;
+ if !metadata.is_file() {
+ return Ok(false);
+ }
+ let age = SystemTime::now().duration_since(metadata.modified()?)?;
+ let size = metadata.len();
+
+ if self.min_age.is_some() && age < self.min_age.unwrap() {
+ return Ok(false);
+ }
+ if self.max_age.is_some() && age > self.max_age.unwrap() {
+ return Ok(false);
+ }
+ if self.min_size.is_some() && size < self.min_size.unwrap() {
+ return Ok(false);
+ }
+ if self.max_size.is_some() && size > self.max_size.unwrap() {
+ return Ok(false);
+ }
+
+ fn is_hidden(path: PathBuf) -> bool {
+ path.file_name()
+ .and_then(|f| f.to_str())
+ .is_some_and(|f| f.starts_with('.'))
+ }
+
+ if self.ignore_hidden_files && is_hidden(dir_entry.path().to_path_buf()) {
+ return Ok(false);
+ }
+
+ Ok(true)
+ }
+
+ fn get_single_file(
+ &self,
+ session: &mut PS,
+ logger: &L,
+ path: &Path,
+ ) -> Result<(), MinifiError> {
+ info!(logger, "GetFile process {:?}", path);
+ let mut ff = session
+ .create()
+ .expect("Successful FlowFile creation is expected");
+
+ if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) {
+ session.set_attribute(&mut ff, FILENAME_OUTPUT_ATTRIBUTE.name, file_name)?;
+ } else {
+ warn!(logger, "Couldnt get filename of {:?}", path);
+ }
+ session.set_attribute(
+ &mut ff,
+ ABSOLUTE_PATH_OUTPUT_ATTRIBUTE.name,
+ path.to_string_lossy().trim(),
+ )?;
+
+ session.write_stream(&ff, |output_stream| {
+ let mut file = File::open(path)?;
+ std::io::copy(&mut file, output_stream)?;
+ Ok(((), IoState::Ok))
+ })?;
+ if !self.keep_source_file
+ && let Err(err) = std::fs::remove_file(path)
+ {
+ warn!(logger, "Failed to remove source file {:?}", err);
+ }
+ session.transfer(ff, relationships::SUCCESS.name)?;
+ Ok(())
+ }
+
+ fn calculate_metrics(&self) -> Vec<(String, f64)> {
+ let metrics = self.metrics.lock().unwrap();
+ vec![
+ ("accepted_files".to_string(), metrics.accepted_files as f64),
+ ("input_bytes".to_string(), metrics.input_bytes as f64),
+ ]
+ }
+}
+
+impl Schedule for GetFileRs {
+ fn schedule(context: &P, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ let input_directory = context.get_req_property::(&DIRECTORY)?;
+ if !input_directory.is_dir() {
+ return Err(MinifiError::schedule_err(format!(
+ "{:?} is not a valid directory",
+ input_directory
+ )));
+ }
+
+ let recursive = context.get_req_property::(&RECURSE)?;
+
+ let keep_source_file = context.get_req_property::(&KEEP_SOURCE_FILE)?;
+
+ let poll_interval = context.get_property::(&properties::POLLING_INTERVAL)?;
+ let min_size = context.get_property::(&MIN_SIZE)?;
+ let max_size = context.get_property::(&MAX_SIZE)?;
+ let min_age = context.get_property::(&MIN_AGE)?;
+ let max_age = context.get_property::(&MAX_AGE)?;
+ let batch_size = context.get_req_property::(&BATCH_SIZE)?;
+ let ignore_hidden_files = context.get_req_property::(&IGNORE_HIDDEN_FILES)?;
+
+ Ok(GetFileRs {
+ recursive,
+ keep_source_file,
+ input_directory,
+ poll_interval,
+ directory_listing: Mutex::new(DirectoryListing::new()),
+ batch_size,
+ min_size,
+ max_size,
+ min_age,
+ max_age,
+ ignore_hidden_files,
+ metrics: Mutex::new(GetFileMetrics {
+ accepted_files: 0,
+ input_bytes: 0,
+ }),
+ })
+ }
+}
+
+impl Trigger for GetFileRs {
+ fn trigger(
+ &self,
+ context: &mut PC,
+ session: &mut PS,
+ logger: &L,
+ ) -> Result
+ where
+ PC: ProcessContext,
+ PS: ProcessSession,
+ L: Logger,
+ {
+ trace!(logger, "on_trigger: {:?}", self);
+ {
+ let is_dir_empty_before_poll = self.is_listing_empty();
+ debug!(
+ logger,
+ "Listing is {} before polling directory", is_dir_empty_before_poll
+ );
+ if is_dir_empty_before_poll && self.should_poll() {
+ self.perform_listing();
+ }
+ }
+ {
+ let is_dir_empty_after_poll = self.is_listing_empty();
+ debug!(
+ logger,
+ "Listing is {} after polling directory", is_dir_empty_after_poll
+ );
+ if is_dir_empty_after_poll {
+ return Ok(OnTriggerResult::Yield);
+ }
+ }
+
+ let files = self.poll_listing(self.batch_size);
+ for file in files {
+ // A single mid-batch failure (deleted, permission flipped,
+ // transient I/O) should not orphan the flow files already
+ // transferred earlier in the same trigger. Log and continue.
+ if let Err(err) = self.get_single_file(session, logger, &file) {
+ warn!(logger, "Failed to ingest {:?}: {}", file, err);
+ }
+ }
+ context.report_metrics(self.calculate_metrics())?;
+ Ok(OnTriggerResult::Ok)
+ }
+}
+
+pub(crate) mod processor_definition;
+
+mod output_attributes;
+#[cfg(test)]
+mod tests;
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/output_attributes.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/output_attributes.rs
new file mode 100644
index 0000000000..1e6e632993
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/output_attributes.rs
@@ -0,0 +1,30 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use minifi_native::OutputAttribute;
+
+pub(crate) const FILENAME_OUTPUT_ATTRIBUTE: OutputAttribute = OutputAttribute {
+ name: "filename",
+ relationships: &["success"],
+ description: "The filename is set to the name of the file on disk",
+};
+
+pub(crate) const ABSOLUTE_PATH_OUTPUT_ATTRIBUTE: OutputAttribute = OutputAttribute {
+ name: "absolute.path",
+ relationships: &["success"],
+ description: "The full/absolute path from where a file was picked up. The current 'path' attribute is still populated, but may be a relative path",
+};
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/processor_definition.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/processor_definition.rs
new file mode 100644
index 0000000000..88e1a4b9a7
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/processor_definition.rs
@@ -0,0 +1,47 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use crate::processors::get_file::output_attributes::{
+ ABSOLUTE_PATH_OUTPUT_ATTRIBUTE, FILENAME_OUTPUT_ATTRIBUTE,
+};
+use crate::processors::get_file::properties::*;
+use crate::processors::get_file::{GetFileRs, relationships};
+use minifi_native::{
+ OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, Property, Relationship,
+};
+
+impl ProcessorDefinition for GetFileRs {
+ const DESCRIPTION: &'static str = "RUST TEST PROCESSOR: Creates FlowFiles from files in a directory. MiNiFi will ignore files for which it doesn't have read permissions.";
+ const INPUT_REQUIREMENT: ProcessorInputRequirement = ProcessorInputRequirement::Forbidden;
+ const SUPPORTS_DYNAMIC_PROPERTIES: bool = false;
+ const SUPPORTS_DYNAMIC_RELATIONSHIPS: bool = false;
+ const OUTPUT_ATTRIBUTES: &'static [OutputAttribute] =
+ &[ABSOLUTE_PATH_OUTPUT_ATTRIBUTE, FILENAME_OUTPUT_ATTRIBUTE];
+ const RELATIONSHIPS: &'static [Relationship] = &[relationships::SUCCESS];
+ const PROPERTIES: &'static [Property] = &[
+ DIRECTORY,
+ POLLING_INTERVAL,
+ RECURSE,
+ KEEP_SOURCE_FILE,
+ MIN_AGE,
+ MAX_AGE,
+ MIN_SIZE,
+ MAX_SIZE,
+ IGNORE_HIDDEN_FILES,
+ BATCH_SIZE,
+ ];
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/properties.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/properties.rs
new file mode 100644
index 0000000000..f27d838417
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/properties.rs
@@ -0,0 +1,119 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use minifi_native::PropertyConstraints::Validator;
+use minifi_native::{Property, StandardPropertyValidator};
+
+pub(crate) const DIRECTORY: Property = Property {
+ name: "Input Directory",
+ description: "The input directory from which to pull files",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: true,
+ default_value: None,
+ constraints: Validator(StandardPropertyValidator::NonBlankValidator),
+};
+
+pub(crate) const RECURSE: Property = Property {
+ name: "Recurse Subdirectories",
+ description: "Indicates whether or not to pull files from subdirectories",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some("true"),
+ constraints: Validator(StandardPropertyValidator::BoolValidator),
+};
+
+pub(crate) const KEEP_SOURCE_FILE: Property = Property {
+ name: "Keep Source File",
+ description: "If true, the file is not deleted after it has been copied to the Content Repository",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some("false"),
+ constraints: Validator(StandardPropertyValidator::BoolValidator),
+};
+
+pub(crate) const MIN_AGE: Property = Property {
+ name: "Minimum File Age",
+ description: "The minimum age that a file must be in order to be pulled; any file younger than this amount of time (according to last modification date) will be ignored",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: Validator(StandardPropertyValidator::TimePeriodValidator),
+};
+
+pub(crate) const MAX_AGE: Property = Property {
+ name: "Maximum File Age",
+ description: "The maximum age that a file must be in order to be pulled; any file older than this amount of time (according to last modification date) will be ignored",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: Validator(StandardPropertyValidator::TimePeriodValidator),
+};
+
+pub(crate) const MIN_SIZE: Property = Property {
+ name: "Minimum File Size",
+ description: "The minimum size that a file can be in order to be pulled",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: Validator(StandardPropertyValidator::DataSizeValidator),
+};
+
+pub(crate) const MAX_SIZE: Property = Property {
+ name: "Maximum File Size",
+ description: "The maximum size that a file can be in order to be pulled",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: Validator(StandardPropertyValidator::DataSizeValidator),
+};
+
+pub(crate) const IGNORE_HIDDEN_FILES: Property = Property {
+ name: "Ignore Hidden Files",
+ description: "Indicates whether or not hidden files should be ignored",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some("true"),
+ constraints: Validator(StandardPropertyValidator::BoolValidator),
+};
+
+pub(crate) const POLLING_INTERVAL: Property = Property {
+ name: "Polling Interval",
+ description: "Indicates how long to wait before performing a directory listing",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: Validator(StandardPropertyValidator::TimePeriodValidator),
+};
+
+pub(crate) const BATCH_SIZE: Property = Property {
+ name: "Batch Size",
+ description: "The maximum number of files to pull in each iteration",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some("10"),
+ constraints: Validator(StandardPropertyValidator::U64Validator),
+};
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/relationships.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/relationships.rs
new file mode 100644
index 0000000000..48207d0ad1
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/relationships.rs
@@ -0,0 +1,23 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use minifi_native::Relationship;
+
+pub(crate) const SUCCESS: Relationship = Relationship {
+ name: "success",
+ description: "The created FlowFiles are transferred here",
+};
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/tests.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/tests.rs
new file mode 100644
index 0000000000..34449bd419
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/get_file/tests.rs
@@ -0,0 +1,189 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use super::*;
+use crate::processors::get_file::relationships::SUCCESS;
+use filetime::FileTime;
+use minifi_native::{MockLogger, MockProcessContext, MockProcessSession};
+use tempfile::TempDir;
+
+#[test]
+fn schedule_fails_without_input_dir() {
+ assert!(matches!(
+ GetFileRs::schedule(&MockProcessContext::new(), &MockLogger::new())
+ .err()
+ .unwrap(),
+ MinifiError::MissingRequiredProperty(_)
+ ));
+}
+
+#[test]
+fn schedule_fails_with_invalid_input_dir() {
+ let mut context = MockProcessContext::new();
+ context.properties.insert(
+ "Input Directory".to_string(),
+ "/invalid_directory".to_string(),
+ );
+ assert!(matches!(
+ GetFileRs::schedule(&context, &MockLogger::new()),
+ Err(MinifiError::ScheduleError(_))
+ ));
+}
+
+#[test]
+fn simple_get_file_test() {
+ let temp_dir = tempfile::tempdir().expect("temp dir is required for testing GetFile");
+ let file_path = temp_dir.path().join("input_file");
+ std::fs::write(&file_path, "test").unwrap();
+
+ let mut context = MockProcessContext::new();
+ context.properties.insert(
+ "Input Directory".to_string(),
+ temp_dir.path().to_str().unwrap().to_string(),
+ );
+
+ let get_file = GetFileRs::schedule(&context, &MockLogger::new()).unwrap();
+
+ let mut session = MockProcessSession::new();
+ get_file
+ .trigger(&mut context, &mut session, &MockLogger::new())
+ .expect("Should succeed");
+ assert_eq!(session.num_of_transferred_flow_files(), 1);
+}
+
+fn make_file(temp_dir: &TempDir, file_name: &str, size: usize, age: Duration) {
+ let path = temp_dir.path().join(file_name);
+ std::fs::write(&path, "a".repeat(size)).unwrap();
+ let file_time = FileTime::from_system_time(SystemTime::now() - age);
+ filetime::set_file_mtime(path, file_time).expect("Cannot set file time");
+}
+
+fn create_test_directory() -> TempDir {
+ let temp_dir = tempfile::tempdir().expect("temp dir is required for testing GetFile");
+ make_file(&temp_dir, "small_new", 10, Duration::from_secs(10));
+ make_file(&temp_dir, "small_old", 11, Duration::from_secs(3600));
+
+ make_file(&temp_dir, "large_new", 1000, Duration::from_secs(0));
+ make_file(&temp_dir, "large_old", 2000, Duration::from_secs(3600));
+ make_file(&temp_dir, ".small_hidden", 10, Duration::from_secs(0));
+ temp_dir
+}
+
+#[test]
+fn complex_dir_without_filters() {
+ let test_directory = create_test_directory();
+
+ let mut context = MockProcessContext::new();
+ context.properties.insert(
+ "Input Directory".to_string(),
+ test_directory.path().to_str().unwrap().to_string(),
+ );
+ context
+ .properties
+ .insert("Batch Size".to_string(), "10".to_string());
+
+ let mut session = MockProcessSession::new();
+ let get_file = GetFileRs::schedule(&context, &MockLogger::new()).unwrap();
+ get_file
+ .trigger(&mut context, &mut session, &MockLogger::new())
+ .expect("Should succeed");
+ assert_eq!(session.num_of_transferred_flow_files(), 4);
+}
+
+fn test_complex_dir_with_filter(
+ property_name: &str,
+ property_vale: &str,
+ expected_filename_part: &str,
+) {
+ let test_directory = create_test_directory();
+
+ let mut context = MockProcessContext::new();
+ context.properties.insert(
+ DIRECTORY.name.to_string(),
+ test_directory.path().to_str().unwrap().to_string(),
+ );
+ context
+ .properties
+ .insert(BATCH_SIZE.name.to_string(), "10".to_string());
+
+ context
+ .properties
+ .insert(property_name.to_string(), property_vale.to_string());
+
+ let mut session = MockProcessSession::new();
+ let get_file = GetFileRs::schedule(&context, &MockLogger::new()).unwrap();
+ get_file
+ .trigger(&mut context, &mut session, &MockLogger::new())
+ .expect("Should succeed");
+ assert_eq!(session.num_of_transferred_flow_files(), 2);
+ let transferred_flow_files = session.transferred_flow_files.borrow();
+ assert!(transferred_flow_files.iter().all(|transfer| {
+ transfer.relationship == SUCCESS.name
+ && transfer
+ .flow_file
+ .attributes
+ .get("filename")
+ .map(|filename| filename.contains(expected_filename_part))
+ .unwrap_or(false)
+ }));
+ let sum_file_len = transferred_flow_files
+ .iter()
+ .fold(0, |acc, transfer| acc + transfer.flow_file.content_len());
+
+ let metrics = get_file.calculate_metrics();
+ assert_eq!(metrics.len(), 2);
+ assert_eq!(metrics[0].0, "accepted_files".to_string());
+ assert_eq!(metrics[0].1, 2.0);
+ assert_eq!(metrics[1].0, "input_bytes".to_string());
+ assert_eq!(metrics[1].1, sum_file_len as f64);
+}
+
+#[test]
+fn complex_dir_with_filters() {
+ test_complex_dir_with_filter(MIN_AGE.name, "5 min", "old");
+ test_complex_dir_with_filter(MAX_AGE.name, "5 min", "new");
+ test_complex_dir_with_filter(MIN_SIZE.name, "50 B", "large");
+ test_complex_dir_with_filter(MAX_SIZE.name, "50 B", "small");
+}
+
+#[test]
+fn test_hidden_files_and_batch_size() {
+ let temp_dir = tempfile::tempdir().expect("temp dir is required for testing GetFile");
+ make_file(&temp_dir, ".one", 10, Duration::from_secs(0));
+ make_file(&temp_dir, ".two", 10, Duration::from_secs(0));
+ make_file(&temp_dir, ".three", 10, Duration::from_secs(0));
+
+ let mut context = MockProcessContext::new();
+ context.properties.insert(
+ DIRECTORY.name.to_string(),
+ temp_dir.path().to_str().unwrap().to_string(),
+ );
+ context
+ .properties
+ .insert(BATCH_SIZE.name.to_string(), "2".to_string());
+
+ context
+ .properties
+ .insert(IGNORE_HIDDEN_FILES.name.to_string(), "false".to_string());
+
+ let mut session = MockProcessSession::new();
+ let get_file = GetFileRs::schedule(&context, &MockLogger::new()).unwrap();
+ get_file
+ .trigger(&mut context, &mut session, &MockLogger::new())
+ .expect("Should succeed");
+ assert_eq!(session.num_of_transferred_flow_files(), 2);
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor.rs
new file mode 100644
index 0000000000..f215d8c781
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor.rs
@@ -0,0 +1,118 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+// This processor is used to test Errors and panic during schedule/trigger
+
+mod properties;
+mod relationships;
+
+use crate::controller_services::lorem_ipsum_controller_service::LoremIpsumControllerService;
+use crate::processors::kamikaze_processor::properties::{
+ NOT_REGISTERED_PROPERTY, SCHEDULE_BEHAVIOUR, TRIGGER_BEHAVIOUR,
+};
+use minifi_native::macros::{ComponentIdentifier, PropertyType};
+use minifi_native::{
+ GetProperty, Logger, MinifiError, OnTriggerResult, ProcessContext, ProcessSession, Schedule,
+ Trigger,
+};
+use strum_macros::{Display, EnumString, IntoStaticStr, VariantNames};
+
+#[derive(
+ Debug, Clone, Copy, PartialEq, Display, EnumString, VariantNames, IntoStaticStr, PropertyType,
+)]
+#[strum(serialize_all = "PascalCase", const_into_str)]
+enum KamikazeBehaviour {
+ ReturnErr,
+ ReturnOk,
+ GetNotRegisteredProperty,
+ GetInvalidControllerService,
+ Panic,
+}
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct KamikazeProcessorRs {
+ trigger_behaviour: KamikazeBehaviour,
+}
+
+impl Schedule for KamikazeProcessorRs {
+ fn schedule(context: &P, _logger: &L) -> Result
+ where
+ Self: Sized,
+ {
+ let trigger_behaviour =
+ context.get_req_property::(&TRIGGER_BEHAVIOUR)?;
+
+ let schedule_behaviour =
+ context.get_req_property::(&SCHEDULE_BEHAVIOUR)?;
+
+ match schedule_behaviour {
+ KamikazeBehaviour::ReturnErr => Err(MinifiError::schedule_err(
+ "it was designed to fail during schedule",
+ )),
+ KamikazeBehaviour::ReturnOk => Ok(KamikazeProcessorRs { trigger_behaviour }),
+ KamikazeBehaviour::GetNotRegisteredProperty => {
+ let _ = context.get_property::(&NOT_REGISTERED_PROPERTY)?;
+ Ok(KamikazeProcessorRs { trigger_behaviour })
+ }
+ KamikazeBehaviour::Panic => {
+ panic!("KamikazeProcessor::schedule panic")
+ }
+ KamikazeBehaviour::GetInvalidControllerService => {
+ unimplemented!("KamikazeProcessor::get_invalid_controller_service");
+ }
+ }
+ }
+}
+
+impl Trigger for KamikazeProcessorRs {
+ fn trigger(
+ &self,
+ context: &mut PC,
+ _session: &mut PS,
+ _logger: &L,
+ ) -> Result
+ where
+ PC: ProcessContext,
+ PS: ProcessSession,
+ L: Logger,
+ {
+ match self.trigger_behaviour {
+ KamikazeBehaviour::ReturnErr => Err(MinifiError::trigger_err(
+ "it was designed to fail in trigger",
+ )),
+ KamikazeBehaviour::ReturnOk => Ok(OnTriggerResult::Ok),
+ KamikazeBehaviour::Panic => {
+ panic!("KamikazeProcessor::trigger panic")
+ }
+ KamikazeBehaviour::GetNotRegisteredProperty => {
+ let _ = context.get_property(&NOT_REGISTERED_PROPERTY, None)?;
+ Ok(OnTriggerResult::Ok)
+ }
+ KamikazeBehaviour::GetInvalidControllerService => {
+ let _ = context.get_controller_service::(
+ &NOT_REGISTERED_PROPERTY,
+ )?;
+ Ok(OnTriggerResult::Ok)
+ }
+ }
+ }
+}
+
+pub(crate) mod processor_definition;
+
+#[cfg(test)]
+mod tests;
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/processor_definition.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/processor_definition.rs
new file mode 100644
index 0000000000..0d50359fc9
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/processor_definition.rs
@@ -0,0 +1,34 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use super::*;
+use minifi_native::{
+ OutputAttribute, ProcessorDefinition, ProcessorInputRequirement, Property, Relationship,
+};
+
+impl ProcessorDefinition for KamikazeProcessorRs {
+ const DESCRIPTION: &'static str = "RUST TEST PROCESSOR: This processor can fail or panic in on_trigger and on_schedule calls based on configuration. Only for testing purposes.";
+ const INPUT_REQUIREMENT: ProcessorInputRequirement = ProcessorInputRequirement::Allowed;
+ const SUPPORTS_DYNAMIC_PROPERTIES: bool = false;
+ const SUPPORTS_DYNAMIC_RELATIONSHIPS: bool = false;
+ const OUTPUT_ATTRIBUTES: &'static [OutputAttribute] = &[];
+ const RELATIONSHIPS: &'static [Relationship] = &[relationships::SUCCESS];
+ const PROPERTIES: &'static [Property] = &[
+ properties::SCHEDULE_BEHAVIOUR,
+ properties::TRIGGER_BEHAVIOUR,
+ ];
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/properties.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/properties.rs
new file mode 100644
index 0000000000..1db6c3adac
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/properties.rs
@@ -0,0 +1,51 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use crate::processors::kamikaze_processor::KamikazeBehaviour;
+use minifi_native::Property;
+use minifi_native::PropertyConstraints::{AllowedValues, NoConstraints};
+use strum::VariantNames;
+
+pub(crate) const SCHEDULE_BEHAVIOUR: Property = Property {
+ name: "Schedule Behaviour",
+ description: "What to do during the on_schedule method",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some(KamikazeBehaviour::ReturnOk.into_str()),
+ constraints: AllowedValues(KamikazeBehaviour::VARIANTS),
+};
+
+pub(crate) const TRIGGER_BEHAVIOUR: Property = Property {
+ name: "Trigger Behaviour",
+ description: "What to do during the trigger method",
+ is_required: true,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: Some(KamikazeBehaviour::ReturnOk.into_str()),
+ constraints: AllowedValues(KamikazeBehaviour::VARIANTS),
+};
+
+pub(crate) const NOT_REGISTERED_PROPERTY: Property = Property {
+ name: "Kamikaze Processor Property",
+ description: "Property purposely left out of Processor description",
+ is_required: false,
+ is_sensitive: false,
+ supports_expr_lang: false,
+ default_value: None,
+ constraints: NoConstraints,
+};
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/relationships.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/relationships.rs
new file mode 100644
index 0000000000..33e94d41a6
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/relationships.rs
@@ -0,0 +1,23 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use minifi_native::Relationship;
+
+pub(crate) const SUCCESS: Relationship = Relationship {
+ name: "success",
+ description: "success relationship",
+};
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/tests.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/tests.rs
new file mode 100644
index 0000000000..f5560a25a8
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/kamikaze_processor/tests.rs
@@ -0,0 +1,96 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+use super::*;
+use crate::processors::kamikaze_processor::properties::{SCHEDULE_BEHAVIOUR, TRIGGER_BEHAVIOUR};
+use minifi_native::MinifiError::{ScheduleError, TriggerError};
+use minifi_native::{MockLogger, MockProcessContext, MockProcessSession};
+use std::panic::AssertUnwindSafe;
+
+#[test]
+fn on_schedule_ok() {
+ let context = MockProcessContext::new();
+ let processor = KamikazeProcessorRs::schedule(&context, &MockLogger::new());
+ assert!(processor.is_ok());
+}
+
+#[test]
+fn on_schedule_err() {
+ let mut context = MockProcessContext::new();
+ context
+ .properties
+ .insert(SCHEDULE_BEHAVIOUR.name.to_string(), "ReturnErr".to_string());
+ let processor = KamikazeProcessorRs::schedule(&context, &MockLogger::new());
+ assert!(matches!(processor, Err(ScheduleError(_))));
+}
+
+#[test]
+fn on_schedule_panic() {
+ let mut context = MockProcessContext::new();
+ context
+ .properties
+ .insert(SCHEDULE_BEHAVIOUR.name.to_string(), "Panic".to_string());
+
+ let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
+ KamikazeProcessorRs::schedule(&context, &MockLogger::new())
+ }));
+ assert!(result.is_err());
+}
+
+#[test]
+fn on_trigger_ok() {
+ let mut context = MockProcessContext::new();
+ let processor = KamikazeProcessorRs::schedule(&context, &MockLogger::new()).unwrap();
+
+ let mut session = MockProcessSession::new();
+ assert_eq!(
+ processor
+ .trigger(&mut context, &mut session, &MockLogger::new())
+ .expect("Should trigger successfully"),
+ OnTriggerResult::Ok
+ );
+}
+
+#[test]
+fn on_trigger_err() {
+ let mut context = MockProcessContext::new();
+ context
+ .properties
+ .insert(TRIGGER_BEHAVIOUR.name.to_string(), "ReturnErr".to_string());
+ let processor = KamikazeProcessorRs::schedule(&context, &MockLogger::new()).unwrap();
+
+ let mut session = MockProcessSession::new();
+ assert!(matches!(
+ processor.trigger(&mut context, &mut session, &MockLogger::new()),
+ Err(TriggerError(_))
+ ));
+}
+
+#[test]
+fn on_trigger_panic() {
+ let mut context = MockProcessContext::new();
+ context
+ .properties
+ .insert(TRIGGER_BEHAVIOUR.name.to_string(), "Panic".to_string());
+ let processor = KamikazeProcessorRs::schedule(&context, &MockLogger::new()).unwrap();
+
+ let mut session = MockProcessSession::new();
+ let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
+ processor.trigger(&mut context, &mut session, &MockLogger::new())
+ }));
+ assert!(result.is_err());
+}
diff --git a/minifi_rust/extensions/minifi_rs_playground/src/processors/log_attribute.rs b/minifi_rust/extensions/minifi_rs_playground/src/processors/log_attribute.rs
new file mode 100644
index 0000000000..c59eb50969
--- /dev/null
+++ b/minifi_rust/extensions/minifi_rs_playground/src/processors/log_attribute.rs
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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
+//
+// https://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.
+
+// This is the (not production ready) reimplementation of the already existing standard LogAttribute processor
+
+use crate::processors::log_attribute::properties::{FLOW_FILES_TO_LOG, LOG_LEVEL, LOG_PAYLOAD};
+use minifi_native::macros::ComponentIdentifier;
+use minifi_native::{
+ GetProperty, LogLevel, Logger, MinifiError, OnTriggerResult, ProcessContext, ProcessSession,
+ Property, Schedule, Trigger, debug, log, trace,
+};
+
+mod properties;
+mod relationships;
+
+#[derive(Debug, ComponentIdentifier)]
+pub(crate) struct LogAttributeRs {
+ log_level: LogLevel,
+ attributes_to_log: Option>,
+ attributes_to_ignore: Option>,
+ log_payload: bool,
+ flow_files_to_log: usize,
+ dash_line: String,
+ hex_encode_payload: bool,
+}
+
+impl LogAttributeRs {
+ fn generate_log_message(&self, session: &mut PS, flow_file: &PS::FlowFile) -> String
+ where
+ PS: ProcessSession,
+ {
+ let mut log_msg = String::with_capacity(1024);
+ log_msg.push_str("Logging for flow file\n");
+ log_msg.push_str(self.dash_line.as_str());
+
+ log_msg.push_str("\nFlowFile Attributes Map Content");
+ session.for_each_attribute(flow_file, |key, value| {
+ if let Some(attributes_to_ignore) = &self.attributes_to_ignore
+ && attributes_to_ignore.iter().any(|ign| ign == key)
+ {
+ return;
+ }
+ if let Some(attributes_to_log) = &self.attributes_to_log
+ && !attributes_to_log.iter().any(|ign| ign == key)
+ {
+ return;
+ }
+ log_msg.push_str(format!("\nkey:{} value:{}", key, value).as_str());
+ });
+ if self.log_payload {
+ log_msg.push_str("\nPayload:\n");
+ if let Some(flow_file_payload) = session.read(flow_file) {
+ if self.hex_encode_payload {
+ log_msg.push_str(&hex::encode(flow_file_payload));
+ } else {
+ log_msg.push_str(
+ String::from_utf8(flow_file_payload)
+ .unwrap_or_default()
+ .as_str(),
+ );
+ }
+ }
+ }
+ log_msg.push('\n');
+ log_msg.push_str(self.dash_line.as_str());
+ log_msg
+ }
+}
+
+impl Trigger for LogAttributeRs {
+ fn trigger(
+ &self,
+ _context: &mut PC,
+ session: &mut PS,
+ logger: &L,
+ ) -> Result
+ where
+ PC: ProcessContext,
+ PS: ProcessSession,
+ L: Logger,
+ {
+ trace!(
+ logger,
+ "enter log attribute, attempting to retrieve {} flow files", self.flow_files_to_log
+ );
+ let max_flow_files_to_process = if self.flow_files_to_log == 0 {
+ usize::MAX
+ } else {
+ self.flow_files_to_log
+ };
+ let mut flow_files_processed = 0usize;
+ for _ in 0..max_flow_files_to_process {
+ if let Some(flow_file) = session.get() {
+ let log_msg = self.generate_log_message(session, &flow_file);
+ log!(logger, self.log_level, "{}", log_msg);
+ session.transfer(flow_file, relationships::SUCCESS.name)?;
+ flow_files_processed += 1;
+ } else {
+ break;
+ }
+ }
+ debug!(logger, "Logged {} flow files", flow_files_processed);
+
+ Ok(OnTriggerResult::Ok)
+ }
+}
+
+impl Schedule for LogAttributeRs {
+ fn schedule(context: &P, _logger: &L) -> Result {
+ let log_level = context.get_req_property::(&LOG_LEVEL)?;
+ let log_payload = context.get_req_property::(&LOG_PAYLOAD)?;
+ let flow_files_to_log = context.get_req_property::(&FLOW_FILES_TO_LOG)?;
+
+ fn get_csv_property(
+ context: &P,
+ property: &Property,
+ ) -> Result