1use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub enum Activity {
23 Unknown,
24 UploadText,
25 UploadFile,
26 DownloadText,
27 DownloadFile,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ActivityMetadata {
33 pub activity: Activity,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct PurviewTextContent {
39 #[serde(rename = "@odata.type")]
40 pub data_type: String,
41 pub data: String,
42}
43
44impl PurviewTextContent {
45 pub fn new(text: impl Into<String>) -> Self {
46 Self {
47 data_type: "microsoft.graph.textContent".to_string(),
48 data: text.into(),
49 }
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ProcessConversationMetadata {
62 #[serde(rename = "@odata.type")]
63 pub data_type: String,
64 pub identifier: String,
65 pub content: PurviewTextContent,
66 pub name: String,
67 #[serde(rename = "isTruncated")]
68 pub is_truncated: bool,
69}
70
71impl ProcessConversationMetadata {
72 pub fn new(
73 identifier: impl Into<String>,
74 text: impl Into<String>,
75 name: impl Into<String>,
76 ) -> Self {
77 Self {
78 data_type: "microsoft.graph.processConversationMetadata".to_string(),
79 identifier: identifier.into(),
80 content: PurviewTextContent::new(text),
81 name: name.into(),
82 is_truncated: false,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct OperatingSystemSpecifications {
93 #[serde(rename = "operatingSystemPlatform")]
94 pub operating_system_platform: String,
95 #[serde(rename = "operatingSystemVersion")]
96 pub operating_system_version: String,
97}
98
99impl Default for OperatingSystemSpecifications {
100 fn default() -> Self {
101 Self {
102 operating_system_platform: "Unknown".to_string(),
103 operating_system_version: "Unknown".to_string(),
104 }
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110pub struct DeviceMetadata {
111 #[serde(rename = "operatingSystemSpecifications")]
112 pub operating_system_specifications: OperatingSystemSpecifications,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct IntegratedAppMetadata {
120 pub name: String,
121 pub version: String,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PolicyLocation {
131 #[serde(rename = "@odata.type")]
132 pub data_type: String,
133 pub value: String,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ProtectedAppMetadata {
140 pub name: String,
141 pub version: String,
142 #[serde(rename = "applicationLocation")]
143 pub application_location: PolicyLocation,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct ContentToProcess {
150 #[serde(rename = "contentEntries")]
151 pub content_entries: Vec<ProcessConversationMetadata>,
152 #[serde(rename = "activityMetadata")]
153 pub activity_metadata: ActivityMetadata,
154 #[serde(rename = "deviceMetadata")]
155 pub device_metadata: DeviceMetadata,
156 #[serde(rename = "integratedAppMetadata")]
157 pub integrated_app_metadata: IntegratedAppMetadata,
158 #[serde(rename = "protectedAppMetadata")]
159 pub protected_app_metadata: ProtectedAppMetadata,
160}
161
162#[derive(Debug, Clone, Serialize)]
169pub struct ProcessContentRequest {
170 #[serde(rename = "contentToProcess")]
171 pub content_to_process: ContentToProcess,
172 #[serde(rename = "userId")]
173 pub user_id: String,
174 #[serde(rename = "tenantId")]
175 pub tenant_id: String,
176 #[serde(rename = "correlationId", skip_serializing_if = "Option::is_none")]
177 pub correlation_id: Option<String>,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub enum DlpAction {
184 BlockAccess,
185 Other,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "camelCase")]
191pub enum RestrictionAction {
192 Block,
193 Other,
194}
195
196#[derive(Debug, Clone, Deserialize)]
201pub struct DlpActionInfo {
202 #[serde(default)]
203 pub action: Option<DlpAction>,
204 #[serde(rename = "restrictionAction", default)]
205 pub restriction_action: Option<RestrictionAction>,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "camelCase")]
214pub enum ProtectionScopeState {
215 NotModified,
216 Modified,
217 UnknownFutureValue,
218}
219
220#[derive(Debug, Clone, Deserialize)]
222pub struct ProcessingError {
223 #[serde(default)]
224 pub message: Option<String>,
225}
226
227#[derive(Debug, Clone, Default, Deserialize)]
230pub struct ProcessContentResponse {
231 #[serde(default)]
232 pub id: Option<String>,
233 #[serde(rename = "protectionScopeState", default)]
234 pub protection_scope_state: Option<ProtectionScopeState>,
235 #[serde(rename = "policyActions", default)]
236 pub policy_actions: Option<Vec<DlpActionInfo>>,
237 #[serde(rename = "processingErrors", default)]
238 pub processing_errors: Option<Vec<ProcessingError>>,
239}
240
241impl ProcessContentResponse {
242 pub fn should_block(&self) -> bool {
248 self.policy_actions.as_deref().is_some_and(|actions| {
249 actions.iter().any(|a| {
250 a.action == Some(DlpAction::BlockAccess)
251 || a.restriction_action == Some(RestrictionAction::Block)
252 })
253 })
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn activity_serializes_as_lower_camel_case() {
263 assert_eq!(
264 serde_json::to_value(Activity::UploadText).unwrap(),
265 serde_json::json!("uploadText")
266 );
267 assert_eq!(
268 serde_json::to_value(Activity::DownloadText).unwrap(),
269 serde_json::json!("downloadText")
270 );
271 }
272
273 #[test]
274 fn dlp_action_and_restriction_action_serialize_as_expected_wire_strings() {
275 assert_eq!(
276 serde_json::to_value(DlpAction::BlockAccess).unwrap(),
277 serde_json::json!("blockAccess")
278 );
279 assert_eq!(
280 serde_json::to_value(RestrictionAction::Block).unwrap(),
281 serde_json::json!("block")
282 );
283 }
284
285 #[test]
286 fn process_content_request_serializes_with_graph_camel_case_and_odata_type() {
287 let content = ContentToProcess {
288 content_entries: vec![ProcessConversationMetadata::new(
289 "msg-1",
290 "hello",
291 "Agent Framework Message msg-1",
292 )],
293 activity_metadata: ActivityMetadata {
294 activity: Activity::UploadText,
295 },
296 device_metadata: DeviceMetadata::default(),
297 integrated_app_metadata: IntegratedAppMetadata {
298 name: "App".into(),
299 version: "1.0".into(),
300 },
301 protected_app_metadata: ProtectedAppMetadata {
302 name: "App".into(),
303 version: "1.0".into(),
304 application_location: PolicyLocation {
305 data_type: "microsoft.graph.policyLocationApplication".into(),
306 value: "app-id".into(),
307 },
308 },
309 };
310 let request = ProcessContentRequest {
311 content_to_process: content,
312 user_id: "user-123".into(),
313 tenant_id: "tenant-456".into(),
314 correlation_id: Some("corr-1".into()),
315 };
316 let value = serde_json::to_value(&request).unwrap();
317 assert_eq!(value["userId"], serde_json::json!("user-123"));
318 assert_eq!(value["tenantId"], serde_json::json!("tenant-456"));
319 assert_eq!(value["correlationId"], serde_json::json!("corr-1"));
320 let entry = &value["contentToProcess"]["contentEntries"][0];
321 assert_eq!(
322 entry["@odata.type"],
323 serde_json::json!("microsoft.graph.processConversationMetadata")
324 );
325 assert_eq!(entry["content"]["data"], serde_json::json!("hello"));
326 assert_eq!(
327 entry["content"]["@odata.type"],
328 serde_json::json!("microsoft.graph.textContent")
329 );
330 assert_eq!(
331 value["contentToProcess"]["activityMetadata"]["activity"],
332 serde_json::json!("uploadText")
333 );
334 assert_eq!(
335 value["contentToProcess"]["protectedAppMetadata"]["applicationLocation"]["@odata.type"],
336 serde_json::json!("microsoft.graph.policyLocationApplication")
337 );
338 assert_eq!(
339 value["contentToProcess"]["deviceMetadata"]["operatingSystemSpecifications"]
340 ["operatingSystemPlatform"],
341 serde_json::json!("Unknown")
342 );
343 }
344
345 #[test]
346 fn process_content_request_omits_correlation_id_when_none() {
347 let content = ContentToProcess {
348 content_entries: vec![],
349 activity_metadata: ActivityMetadata {
350 activity: Activity::UploadText,
351 },
352 device_metadata: DeviceMetadata::default(),
353 integrated_app_metadata: IntegratedAppMetadata {
354 name: "A".into(),
355 version: "1".into(),
356 },
357 protected_app_metadata: ProtectedAppMetadata {
358 name: "A".into(),
359 version: "1".into(),
360 application_location: PolicyLocation {
361 data_type: "microsoft.graph.policyLocationApplication".into(),
362 value: "v".into(),
363 },
364 },
365 };
366 let request = ProcessContentRequest {
367 content_to_process: content,
368 user_id: "u".into(),
369 tenant_id: "t".into(),
370 correlation_id: None,
371 };
372 let value = serde_json::to_value(&request).unwrap();
373 assert!(value.get("correlationId").is_none());
374 }
375
376 #[test]
379 fn should_block_true_on_block_access_action() {
380 let resp = ProcessContentResponse {
381 policy_actions: Some(vec![DlpActionInfo {
382 action: Some(DlpAction::BlockAccess),
383 restriction_action: None,
384 }]),
385 ..Default::default()
386 };
387 assert!(resp.should_block());
388 }
389
390 #[test]
391 fn should_block_true_on_block_restriction_action() {
392 let resp = ProcessContentResponse {
393 policy_actions: Some(vec![DlpActionInfo {
394 action: None,
395 restriction_action: Some(RestrictionAction::Block),
396 }]),
397 ..Default::default()
398 };
399 assert!(resp.should_block());
400 }
401
402 #[test]
403 fn should_block_false_when_action_is_other() {
404 let resp = ProcessContentResponse {
405 policy_actions: Some(vec![DlpActionInfo {
406 action: Some(DlpAction::Other),
407 restriction_action: Some(RestrictionAction::Other),
408 }]),
409 ..Default::default()
410 };
411 assert!(!resp.should_block());
412 }
413
414 #[test]
415 fn should_block_false_when_no_policy_actions() {
416 assert!(!ProcessContentResponse::default().should_block());
417 let resp = ProcessContentResponse {
418 policy_actions: Some(vec![]),
419 ..Default::default()
420 };
421 assert!(!resp.should_block());
422 }
423
424 #[test]
425 fn should_block_true_when_any_of_several_actions_blocks() {
426 let resp = ProcessContentResponse {
427 policy_actions: Some(vec![
428 DlpActionInfo {
429 action: Some(DlpAction::Other),
430 restriction_action: None,
431 },
432 DlpActionInfo {
433 action: Some(DlpAction::BlockAccess),
434 restriction_action: None,
435 },
436 ]),
437 ..Default::default()
438 };
439 assert!(resp.should_block());
440 }
441
442 #[test]
443 fn process_content_response_deserializes_from_graph_shape() {
444 let value = serde_json::json!({
445 "id": "resp-1",
446 "protectionScopeState": "notModified",
447 "policyActions": [{"action": "blockAccess", "restrictionAction": "block"}],
448 });
449 let resp: ProcessContentResponse = serde_json::from_value(value).unwrap();
450 assert_eq!(resp.id.as_deref(), Some("resp-1"));
451 assert_eq!(
452 resp.protection_scope_state,
453 Some(ProtectionScopeState::NotModified)
454 );
455 assert!(resp.should_block());
456 }
457
458 #[test]
459 fn process_content_response_deserializes_empty_object() {
460 let resp: ProcessContentResponse = serde_json::from_value(serde_json::json!({})).unwrap();
461 assert!(resp.id.is_none());
462 assert!(!resp.should_block());
463 }
464}