jmap_filenode_types/filenode.rs
1//! draft-ietf-jmap-filenode-13 §3.1 — FileNode object and component types.
2//!
3//! Provides [`FileNode`], [`NodeType`], [`NodeRole`], and [`FilesRights`].
4
5use std::collections::HashMap;
6
7use jmap_types::{impl_string_enum, Id, UTCDate};
8use serde::{Deserialize, Serialize};
9
10/// The type of a FileNode (draft-ietf-jmap-filenode-13 §3.1, IANA "JMAP FileNode Types"
11/// registry §10.4).
12///
13/// Values are registered strings. Any unrecognised value is preserved as
14/// [`NodeType::Other`] so clients do not lose data when the registry gains new entries.
15#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16#[non_exhaustive]
17pub enum NodeType {
18 /// Regular file; `blobId` MUST be non-null.
19 File,
20 /// Collection that may contain child nodes; `blobId` MUST be null.
21 Directory,
22 /// Symbolic link; `target` MUST be non-null and `blobId` MUST be null.
23 Symlink,
24 /// Any node type string not recognised by this implementation.
25 ///
26 /// The inner string retains the original wire value for round-trip fidelity.
27 Other(String),
28}
29
30impl_string_enum!(NodeType, "a JMAP FileNode type string",
31 "file" => File,
32 "directory" => Directory,
33 "symlink" => Symlink,
34);
35
36impl NodeType {
37 /// Return the wire-format string for this node type.
38 pub fn to_wire_str(&self) -> &str {
39 match self {
40 Self::File => "file",
41 Self::Directory => "directory",
42 Self::Symlink => "symlink",
43 Self::Other(s) => s.as_str(),
44 }
45 }
46}
47
48/// Special role identifying a directory's common purpose
49/// (draft-ietf-jmap-filenode-13 §3.1, IANA "JMAP FileNode Roles" registry §10.5).
50///
51/// Clients MUST ignore unrecognised role values (§3.1). Unknown values are preserved
52/// as [`NodeRole::Other`] so they round-trip correctly.
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54#[non_exhaustive]
55pub enum NodeRole {
56 /// Base of a filesystem; should have `parentId` null.
57 Root,
58 /// User's home directory.
59 Home,
60 /// Temporary space; may be cleaned up automatically.
61 Temp,
62 /// Deleted data.
63 Trash,
64 /// Document storage.
65 Documents,
66 /// Downloaded files.
67 Downloads,
68 /// Audio files.
69 Music,
70 /// Photos and images.
71 Pictures,
72 /// Video files.
73 Videos,
74 /// Any role string not recognised by this implementation.
75 Other(String),
76}
77
78impl_string_enum!(NodeRole, "a JMAP FileNode role string",
79 "root" => Root,
80 "home" => Home,
81 "temp" => Temp,
82 "trash" => Trash,
83 "documents" => Documents,
84 "downloads" => Downloads,
85 "music" => Music,
86 "pictures" => Pictures,
87 "videos" => Videos,
88);
89
90impl NodeRole {
91 /// Return the wire-format string for this role.
92 pub fn to_wire_str(&self) -> &str {
93 match self {
94 Self::Root => "root",
95 Self::Home => "home",
96 Self::Temp => "temp",
97 Self::Trash => "trash",
98 Self::Documents => "documents",
99 Self::Downloads => "downloads",
100 Self::Music => "music",
101 Self::Pictures => "pictures",
102 Self::Videos => "videos",
103 Self::Other(s) => s.as_str(),
104 }
105 }
106}
107
108/// ACL rights the authenticated user (or a shared user) holds for a FileNode
109/// (draft-ietf-jmap-filenode-13 §3.1, `myRights` description).
110///
111/// `Default` produces all-false (no access), which is the most restrictive valid
112/// value and a safe starting point when constructing rights in tests or server code.
113#[non_exhaustive]
114#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct FilesRights {
117 /// User may read the properties and blob content of this node.
118 pub may_read: bool,
119 /// User may create child nodes in this directory.
120 pub may_add_children: bool,
121 /// User may rename or move this node.
122 pub may_rename: bool,
123 /// User may destroy this node.
124 pub may_delete: bool,
125 /// User may update content-related properties (`blobId`, `type`, `target`,
126 /// `modified`, `accessed`, `executable`).
127 pub may_modify_content: bool,
128 /// User may change the sharing of this node (see RFC 9670 JMAP Sharing).
129 pub may_share: bool,
130
131 /// Catch-all for vendor / site / private extension fields not covered
132 /// by the typed fields above. Preserves unknown fields across
133 /// deserialize/serialize round-trip per workspace extras-preservation
134 /// policy (see workspace AGENTS.md).
135 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
136 pub extra: serde_json::Map<String, serde_json::Value>,
137}
138
139/// A JMAP FileNode object (draft-ietf-jmap-filenode-13 §3.1).
140///
141/// ## Nullable vs optional fields
142///
143/// Several fields are **required-but-nullable**: they MUST appear in the wire JSON
144/// even when their value is `null`. These use `Option<T>` with NO
145/// `skip_serializing_if`, so serde emits `"field":null`.
146///
147/// Other fields are **truly optional**: absent from the wire when not set by the
148/// client or server. These use `#[serde(skip_serializing_if = "Option::is_none")]`.
149///
150/// | Nullable (must appear as `null`) | Optional (absent when `None`) |
151/// |---|---|
152/// | `parent_id`, `blob_id`, `target`, `size`, `media_type`, `share_with`, `role` | `node_type`, `created`, `modified`, `accessed`, `changed`, `executable`, `is_subscribed`, `my_rights` |
153///
154/// ## `modified` and `accessed` semantics
155///
156/// Both are client-managed. The server does NOT automatically update them on change.
157/// Setting either to `null` in an update (`None` after deserialization) signals the
158/// server to reset it to the current time. This differs from `changed`, which the
159/// server sets automatically and clients cannot set.
160///
161/// ## `share_with` visibility
162///
163/// `shareWith` is `null` when the requesting user lacks `myRights.mayShare` or the
164/// node is not shared with anyone.
165///
166/// ## `blob_id` lifetime guarantee
167///
168/// A blob referenced by a FileNode MUST NOT be garbage-collected by the server while
169/// the FileNode exists (§3.1). The server backend must enforce this invariant.
170#[non_exhaustive]
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct FileNode {
174 /// Server-assigned immutable identifier.
175 pub id: Id,
176
177 /// Id of the parent node, or `null` if this is a top-level node.
178 ///
179 /// Required-and-nullable: always present in wire JSON (as `null` or an Id string).
180 pub parent_id: Option<Id>,
181
182 /// The type of node. Immutable after creation.
183 ///
184 /// If absent on creation the server infers: `"file"` if `blobId` is non-null,
185 /// `"symlink"` if `target` is non-null, `"directory"` otherwise.
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub node_type: Option<NodeType>,
188
189 /// The blobId for the file content.
190 ///
191 /// Required-and-nullable: always present in wire JSON.
192 /// MUST be non-null for file nodes, null for directory and symlink nodes.
193 pub blob_id: Option<Id>,
194
195 /// Symlink target as an array of path elements.
196 ///
197 /// Required-and-nullable: always present in wire JSON.
198 /// MUST be non-null for symlink nodes, null for file and directory nodes.
199 pub target: Option<Vec<String>>,
200
201 /// Size in bytes of the associated blob data (server-set).
202 ///
203 /// Required-and-nullable: always present in wire JSON.
204 /// MUST be null for directory and symlink nodes, non-null for file nodes.
205 pub size: Option<u64>,
206
207 /// User-visible name for the FileNode. Net-Unicode, at least 1 character.
208 pub name: String,
209
210 /// The media type (IANA) of the FileNode.
211 ///
212 /// Required-and-nullable: always present in wire JSON.
213 /// Wire field name is literally `"type"` (a Rust keyword).
214 /// MUST be non-null for file nodes, null for directory and symlink nodes.
215 #[serde(rename = "type")]
216 pub media_type: Option<String>,
217
218 /// The date the node was created.
219 ///
220 /// Default: current server time. Absent from wire when `None`.
221 ///
222 /// Uses the [`UTCDate`] newtype to make the wire-format constraint
223 /// (RFC 8620 §1.4: 20-character UTCDateTime string) explicit at the
224 /// type level. JSON wire format is unchanged because `UTCDate` is a
225 /// transparent newtype around `String`.
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub created: Option<UTCDate>,
228
229 /// The date the node was last updated, client-managed.
230 ///
231 /// Setting to `null` in an update signals the server to reset to the current time.
232 /// The server does NOT auto-update this value. Absent from wire when `None`.
233 ///
234 /// See [`created`](Self::created) for the typing rationale.
235 #[serde(skip_serializing_if = "Option::is_none")]
236 pub modified: Option<UTCDate>,
237
238 /// The date the node was last accessed, client-managed.
239 ///
240 /// Setting to `null` in an update signals the server to reset to the current time.
241 /// The server does NOT auto-update this value. Absent from wire when `None`.
242 ///
243 /// See [`created`](Self::created) for the typing rationale.
244 #[serde(skip_serializing_if = "Option::is_none")]
245 pub accessed: Option<UTCDate>,
246
247 /// The date the server last recorded a change to any property (server-set).
248 ///
249 /// Automatically updated on every mutation. Not settable by clients.
250 /// Absent from wire when `None`.
251 ///
252 /// Uses the [`UTCDate`] newtype to make the wire-format constraint
253 /// (RFC 8620 §1.4: 20-character UTCDateTime string) explicit at the
254 /// type level. JSON wire format is unchanged because `UTCDate` is a
255 /// `#[serde(transparent)]` newtype around `String`.
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub changed: Option<UTCDate>,
258
259 /// If true, the node should be treated as executable. Default: false.
260 ///
261 /// Absent from wire when `None`.
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub executable: Option<bool>,
264
265 /// Whether the current user is subscribed to this node. Default: true.
266 ///
267 /// Per-user property. Absent from wire when `None`.
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub is_subscribed: Option<bool>,
270
271 /// ACL rights the authenticated user holds for this node (server-set).
272 ///
273 /// Absent from wire when `None`.
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub my_rights: Option<FilesRights>,
276
277 /// Map of userId → rights for users this node is shared with.
278 ///
279 /// Required-and-nullable: always present in wire JSON.
280 /// `null` when the requesting user lacks `myRights.mayShare` or the node is
281 /// not shared.
282 pub share_with: Option<HashMap<Id, FilesRights>>,
283
284 /// Special role identifying this directory's purpose.
285 ///
286 /// Required-and-nullable (draft §3.1 type: `String|null`): always present
287 /// in wire JSON; serializes as `null` when unset. MUST be `null` for
288 /// file nodes.
289 pub role: Option<NodeRole>,
290
291 /// Catch-all for vendor / site / private extension fields not covered
292 /// by the typed fields above. Preserves unknown fields across
293 /// deserialize/serialize round-trip per workspace extras-preservation
294 /// policy (see workspace AGENTS.md).
295 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
296 pub extra: serde_json::Map<String, serde_json::Value>,
297}