Skip to main content

clawdstrike_ocsf/classes/
file_activity.rs

1//! OCSF File Activity (class_uid = 1001, category_uid = 1 System Activity).
2//!
3//! Activity IDs: 1=Create, 2=Read, 3=Update, 4=Delete, 14=Open.
4
5use serde::{Deserialize, Serialize};
6
7use crate::base::{category_for_class, compute_type_uid, ClassUid};
8use crate::objects::actor::Actor;
9use crate::objects::file::OcsfFile;
10use crate::objects::metadata::Metadata;
11
12/// OCSF activity IDs for File Activity.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[repr(u8)]
15pub enum FileActivityType {
16    /// File created.
17    Create = 1,
18    /// File read.
19    Read = 2,
20    /// File updated / written.
21    Update = 3,
22    /// File deleted.
23    Delete = 4,
24    /// File opened.
25    Open = 14,
26    /// Other (vendor-specific).
27    Other = 99,
28}
29
30impl FileActivityType {
31    /// Returns the integer representation.
32    #[must_use]
33    pub const fn as_u8(self) -> u8 {
34        self as u8
35    }
36}
37
38/// OCSF File Activity event (class_uid = 1001).
39#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct FileActivity {
42    // ── OCSF base fields ──
43    /// Always 1001.
44    pub class_uid: u16,
45    /// Always 1 (System Activity).
46    pub category_uid: u8,
47    /// `class_uid * 100 + activity_id`.
48    pub type_uid: u32,
49    /// Activity ID.
50    pub activity_id: u8,
51    /// Human-readable activity name.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub activity_name: Option<String>,
54    /// Event time as epoch milliseconds.
55    pub time: i64,
56    /// Severity ID (0-6, 99).
57    pub severity_id: u8,
58    /// Human-readable severity label.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub severity: Option<String>,
61    /// Status ID (0=Unknown, 1=Success, 2=Failure).
62    pub status_id: u8,
63    /// Human-readable status label.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub status: Option<String>,
66    /// Human-readable event message.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub message: Option<String>,
69    /// Metadata (required).
70    pub metadata: Metadata,
71
72    // ── File Activity-specific fields ──
73    /// The file being accessed (required).
74    pub file: OcsfFile,
75    /// Actor performing the file operation.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub actor: Option<Actor>,
78    /// Vendor-specific unmapped data.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub unmapped: Option<serde_json::Value>,
81}
82
83impl FileActivity {
84    /// Create a new File Activity event with required fields.
85    #[must_use]
86    pub fn new(
87        activity: FileActivityType,
88        time: i64,
89        severity_id: u8,
90        status_id: u8,
91        metadata: Metadata,
92        file: OcsfFile,
93    ) -> Self {
94        let class_uid = ClassUid::FileActivity;
95        let activity_id = activity.as_u8();
96        Self {
97            class_uid: class_uid.as_u16(),
98            category_uid: category_for_class(class_uid).as_u8(),
99            type_uid: compute_type_uid(class_uid.as_u16(), activity_id),
100            activity_id,
101            activity_name: Some(file_activity_name(activity).to_string()),
102            time,
103            severity_id,
104            severity: None,
105            status_id,
106            status: None,
107            message: None,
108            metadata,
109            file,
110            actor: None,
111            unmapped: None,
112        }
113    }
114
115    /// Set the event message.
116    #[must_use]
117    pub fn with_message(mut self, msg: impl Into<String>) -> Self {
118        self.message = Some(msg.into());
119        self
120    }
121
122    /// Set the actor.
123    #[must_use]
124    pub fn with_actor(mut self, actor: Actor) -> Self {
125        self.actor = Some(actor);
126        self
127    }
128}
129
130fn file_activity_name(activity: FileActivityType) -> &'static str {
131    match activity {
132        FileActivityType::Create => "Create",
133        FileActivityType::Read => "Read",
134        FileActivityType::Update => "Update",
135        FileActivityType::Delete => "Delete",
136        FileActivityType::Open => "Open",
137        FileActivityType::Other => "Other",
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn sample_file() -> OcsfFile {
146        OcsfFile {
147            path: Some("/etc/shadow".to_string()),
148            name: Some("shadow".to_string()),
149            uid: None,
150            type_id: None,
151            size: None,
152            hashes: None,
153        }
154    }
155
156    #[test]
157    fn class_uid_is_1001() {
158        let e = FileActivity::new(
159            FileActivityType::Read,
160            0,
161            0,
162            0,
163            Metadata::clawdstrike("0.1.3"),
164            sample_file(),
165        );
166        assert_eq!(e.class_uid, 1001);
167    }
168
169    #[test]
170    fn category_uid_is_1() {
171        let e = FileActivity::new(
172            FileActivityType::Read,
173            0,
174            0,
175            0,
176            Metadata::clawdstrike("0.1.3"),
177            sample_file(),
178        );
179        assert_eq!(e.category_uid, 1);
180    }
181
182    #[test]
183    fn type_uid_read() {
184        let e = FileActivity::new(
185            FileActivityType::Read,
186            0,
187            0,
188            0,
189            Metadata::clawdstrike("0.1.3"),
190            sample_file(),
191        );
192        assert_eq!(e.type_uid, 100102);
193    }
194
195    #[test]
196    fn type_uid_update() {
197        let e = FileActivity::new(
198            FileActivityType::Update,
199            0,
200            0,
201            0,
202            Metadata::clawdstrike("0.1.3"),
203            sample_file(),
204        );
205        assert_eq!(e.type_uid, 100103);
206    }
207
208    #[test]
209    fn type_uid_open() {
210        let e = FileActivity::new(
211            FileActivityType::Open,
212            0,
213            0,
214            0,
215            Metadata::clawdstrike("0.1.3"),
216            sample_file(),
217        );
218        // 1001 * 100 + 14 = 100114
219        assert_eq!(e.type_uid, 100114);
220    }
221
222    #[test]
223    fn serialization_roundtrip() {
224        let e = FileActivity::new(
225            FileActivityType::Update,
226            1_709_366_400_000,
227            4,
228            2,
229            Metadata::clawdstrike("0.1.3"),
230            sample_file(),
231        )
232        .with_message("File write blocked");
233
234        let json = serde_json::to_string(&e).unwrap();
235        let e2: FileActivity = serde_json::from_str(&json).unwrap();
236        assert_eq!(e.type_uid, e2.type_uid);
237        assert_eq!(e.file.path, e2.file.path);
238    }
239}