1use std::collections::{HashMap, HashSet};
2
3use anyhow::{Context, Result, bail};
4use chrono::{NaiveDate, NaiveTime};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::attachments::validation::{
9 MAX_BLOB_BYTES, validate_alt_text, validate_blob_size, validate_dimensions, validate_filename,
10 validate_media_type,
11};
12use crate::change_log::op_type;
13use crate::choices::TaskSource;
14use crate::ids::{BASE32, MetadataFieldId, ProjectId, WorkspaceId};
15use crate::recurrence::{
16 RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceRule,
17 RecurrenceSchedule, RecurrenceSeriesId, RecurrenceSeriesState, TimeZoneId, WeekdaySet,
18 derive_occurrence_identity, is_slot, next_slot_after,
19};
20use crate::task_fields::TaskField;
21
22pub const SYNC_PROTOCOL_VERSION: u32 = 13;
23const MAX_CHANGE_PAYLOAD_BYTES: usize = 64 * 1024;
24pub fn sync_server_url_is_valid(server: &str) -> bool {
25 let Ok(url) = url::Url::parse(server) else {
26 return false;
27 };
28 matches!(url.scheme(), "http" | "https")
29 && url.host_str().is_some()
30 && url.username().is_empty()
31 && url.password().is_none()
32 && url.query().is_none()
33 && url.fragment().is_none()
34}
35pub const MAX_PUSH_BATCH: usize = 256;
36pub const MAX_PULL_BATCH: u32 = 512;
37pub const MAX_BLOB_TRANSFER_OBJECTS: usize = 16;
38pub const MAX_BLOB_TRANSFER_BYTES: u64 = 64 * 1024 * 1024;
39pub const DAEMON_SYNC_PAGE_BUDGET: usize = 8;
40pub const DAEMON_INCOMPLETE_RESCHEDULE_MS: u64 = 100;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ChangeWire {
44 pub change_id: String,
45 pub client_id: String,
46 pub local_seq: i64,
47 pub entity_type: String,
48 pub entity_id: String,
49 pub field: Option<String>,
50 pub op_type: String,
51 pub payload: Value,
52 pub base_version: Option<String>,
53 pub created_at: String,
54 pub server_seq: Option<i64>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PushAck {
59 pub change_id: String,
60 pub server_seq: i64,
61}
62
63#[derive(Debug)]
64pub struct ChangeRow {
65 pub change_id: String,
66 pub client_id: String,
67 pub local_seq: i64,
68 pub entity_type: String,
69 pub entity_id: String,
70 pub field: Option<String>,
71 pub op_type: String,
72 pub payload: String,
73 pub base_version: Option<String>,
74 pub created_at: String,
75 pub server_seq: Option<i64>,
76}
77
78impl ChangeRow {
79 pub fn into_wire(self) -> ChangeWire {
80 ChangeWire {
81 change_id: self.change_id,
82 client_id: self.client_id,
83 local_seq: self.local_seq,
84 entity_type: self.entity_type,
85 entity_id: self.entity_id,
86 field: self.field,
87 op_type: self.op_type,
88 payload: serde_json::from_str(&self.payload).unwrap_or(Value::Null),
89 base_version: self.base_version,
90 created_at: self.created_at,
91 server_seq: self.server_seq,
92 }
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SyncRequest {
98 #[serde(default)]
99 pub protocol_version: Option<u32>,
100 pub client_id: String,
101 pub after: i64,
102 #[serde(default)]
103 pub pull_limit: Option<u32>,
104 pub changes: Vec<ChangeWire>,
105}
106
107#[derive(Debug, Serialize, Deserialize)]
108pub struct SyncResponse {
109 pub protocol_version: u32,
110 pub cursor: i64,
111 pub has_more: bool,
112 #[serde(default)]
113 pub push_acks: Vec<PushAck>,
114 pub changes: Vec<ChangeWire>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct BlobUploadContract {
119 pub workspace_id: String,
120 pub sha256: String,
121 pub byte_size: i64,
122 pub media_type: String,
123 pub width: i64,
124 pub height: i64,
125}
126
127#[derive(Debug, Serialize, Deserialize)]
128pub struct MissingBlobsRequest {
129 pub blobs: Vec<BlobUploadContract>,
130}
131
132#[derive(Debug, Serialize, Deserialize)]
133pub struct MissingBlobsResponse {
134 pub missing: Vec<String>,
135}
136
137pub fn validate_blob_contracts(blobs: &[BlobUploadContract]) -> Result<()> {
138 if blobs.len() > MAX_PUSH_BATCH {
139 bail!(
140 "error blob-batch-too-large limit={} got={}",
141 MAX_PUSH_BATCH,
142 blobs.len()
143 );
144 }
145 let mut seen = HashSet::with_capacity(blobs.len());
146 let mut hashes = HashSet::with_capacity(blobs.len());
147 for blob in blobs {
148 ensure_sync_id("workspace_id", &blob.workspace_id)?;
149 validate_sha256_for_sync(&blob.sha256)?;
150 validate_blob_size_for_sync(blob.byte_size)?;
151 map_attachment_validation(validate_media_type(&blob.media_type))?;
152 map_attachment_validation(validate_dimensions(Some(blob.width), Some(blob.height)))?;
153 if !seen.insert((blob.workspace_id.as_str(), blob.sha256.as_str())) {
154 bail!("error duplicate-blob-contract");
155 }
156 hashes.insert(blob.sha256.as_str());
157 }
158 if hashes.len() > MAX_BLOB_TRANSFER_OBJECTS {
159 bail!(
160 "error blob-batch-too-large limit={} got={}",
161 MAX_BLOB_TRANSFER_OBJECTS,
162 hashes.len()
163 );
164 }
165 Ok(())
166}
167
168pub fn validate_blob_hashes(hashes: &[String]) -> Result<()> {
169 if hashes.len() > MAX_BLOB_TRANSFER_OBJECTS {
170 bail!(
171 "error blob-batch-too-large limit={} got={}",
172 MAX_BLOB_TRANSFER_OBJECTS,
173 hashes.len()
174 );
175 }
176 let mut seen = HashSet::with_capacity(hashes.len());
177 for hash in hashes {
178 validate_sha256_for_sync(hash)?;
179 if !seen.insert(hash.as_str()) {
180 bail!("error duplicate-blob-hash");
181 }
182 }
183 Ok(())
184}
185
186#[derive(Debug)]
187struct SyncProtocolError {
188 client: u32,
189 server: u32,
190}
191
192impl std::fmt::Display for SyncProtocolError {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 write!(
195 f,
196 "error sync-protocol-unsupported client={} server={}",
197 self.client, self.server
198 )
199 }
200}
201
202impl std::error::Error for SyncProtocolError {}
203
204fn sync_protocol_error(client: u32, server: u32) -> anyhow::Error {
205 anyhow::Error::new(SyncProtocolError { client, server })
206}
207
208pub fn validate_sync_protocol_version(client: u32, server: u32) -> Result<()> {
209 if client != server {
210 return Err(sync_protocol_error(client, server));
211 }
212 Ok(())
213}
214
215pub fn validate_sync_request_protocol_version(client: Option<u32>) -> Result<()> {
216 validate_sync_protocol_version(client.unwrap_or(0), SYNC_PROTOCOL_VERSION)
217}
218
219pub fn request_pull_limit(requested: Option<u32>) -> Result<u32> {
220 match requested {
221 None => Ok(MAX_PULL_BATCH),
222 Some(limit @ 1..=MAX_PULL_BATCH) => Ok(limit),
223 Some(limit) => {
224 bail!("error sync-pull-limit-out-of-range min=1 max={MAX_PULL_BATCH} got={limit}")
225 }
226 }
227}
228
229#[derive(Debug, Clone, Copy)]
230pub struct ValidatedSyncRequestEnvelope {
231 pub after: i64,
232 pub pull_limit: u32,
233 pub push_count: usize,
234}
235
236pub fn validate_sync_request_envelope(
237 request: &SyncRequest,
238) -> Result<ValidatedSyncRequestEnvelope> {
239 validate_sync_request_protocol_version(request.protocol_version)?;
240 validate_request_cursor(request.after)?;
241 validate_push_batch_size(request.changes.len())?;
242 Ok(ValidatedSyncRequestEnvelope {
243 after: request.after,
244 pull_limit: request_pull_limit(request.pull_limit)?,
245 push_count: request.changes.len(),
246 })
247}
248
249fn validate_request_cursor(after: i64) -> Result<()> {
250 if after < 0 {
251 bail!("error sync-after-out-of-range min=0 got={after}");
252 }
253 Ok(())
254}
255
256fn validate_push_batch_size(len: usize) -> Result<()> {
257 if len > MAX_PUSH_BATCH {
258 bail!("error sync-push-too-large limit={MAX_PUSH_BATCH} got={len}");
259 }
260 Ok(())
261}
262
263pub fn validate_sync_response_for_request(
264 after: i64,
265 pull_limit: u32,
266 request_change_ids: &[String],
267 response: &SyncResponse,
268) -> Result<()> {
269 validate_sync_protocol_version(SYNC_PROTOCOL_VERSION, response.protocol_version)?;
270 if response.changes.len() > pull_limit as usize {
271 bail!(
272 "error invalid-sync-response pull-too-large limit={} got={}",
273 pull_limit,
274 response.changes.len()
275 );
276 }
277 if response.cursor < after {
278 bail!(
279 "error invalid-sync-response cursor-regressed after={} cursor={}",
280 after,
281 response.cursor
282 );
283 }
284 validate_push_acks(request_change_ids, response)?;
285 validate_pull_page(after, pull_limit, response)?;
286 validate_push_pull_overlap(response)?;
287 Ok(())
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291enum ChangeDirection {
292 Pushed,
293 Pulled,
294}
295
296pub fn validate_pushed_change(change: &ChangeWire) -> Result<()> {
297 validate_change_shape(change, ChangeDirection::Pushed)
298}
299
300fn validate_pulled_change(change: &ChangeWire) -> Result<()> {
301 validate_change_shape(change, ChangeDirection::Pulled)
302}
303
304fn validate_change_shape(change: &ChangeWire, direction: ChangeDirection) -> Result<()> {
305 ensure_non_empty("change_id", &change.change_id)?;
306 ensure_non_empty("client_id", &change.client_id)?;
307 ensure_non_empty("entity_id", &change.entity_id)?;
308 ensure_non_empty("op_type", &change.op_type)?;
309 ensure_non_empty("entity_type", &change.entity_type)?;
310 if direction == ChangeDirection::Pushed {
311 ensure_sync_id("change_id", &change.change_id)?;
312 }
313 validate_change_server_seq(change, direction)?;
314 if !change.payload.is_object() {
315 bail!("error invalid-sync-change payload expected-object");
316 }
317 if serde_json::to_vec(&change.payload)?.len() > MAX_CHANGE_PAYLOAD_BYTES {
318 bail!("error invalid-sync-change payload-too-large limit={MAX_CHANGE_PAYLOAD_BYTES}");
319 }
320
321 match change.op_type.as_str() {
322 op_type::CREATE_WORKSPACE => {
323 ensure_entity_type(change, "workspace")?;
324 ensure_sync_id("entity_id", &change.entity_id)?;
325 required_string_payload("key", &change.payload)?;
326 required_string_payload("name", &change.payload)?;
327 required_string_payload("created_at", &change.payload)?;
328 }
329 op_type::SET_WORKSPACE_FIELD => {
330 ensure_entity_type(change, "workspace")?;
331 ensure_sync_id("entity_id", &change.entity_id)?;
332 let field = change
333 .field
334 .as_deref()
335 .filter(|field| !field.trim().is_empty())
336 .context("error invalid-sync-change field missing")?;
337 if !matches!(field, "name" | "key") {
338 bail!("error invalid-sync-change field={field}");
339 }
340 required_string_payload("value", &change.payload)?;
341 }
342 op_type::CREATE_PROJECT => {
343 ensure_entity_type(change, "project")?;
344 ensure_project_id("entity_id", &change.entity_id)?;
345 optional_workspace_payload(&change.payload)?;
346 required_string_payload("key", &change.payload)?;
347 required_string_payload("name", &change.payload)?;
348 required_string_payload("prefix", &change.payload)?;
349 required_string_payload("created_at", &change.payload)?;
350 }
351 op_type::SET_PROJECT_METADATA => {
352 ensure_entity_type(change, "project")?;
353 ensure_project_id("entity_id", &change.entity_id)?;
354 optional_workspace_payload(&change.payload)?;
355 required_string_payload("key", &change.payload)?;
356 required_string_payload("name", &change.payload)?;
357 required_string_payload("prefix", &change.payload)?;
358 required_string_payload("updated_at", &change.payload)?;
359 }
360 op_type::CREATE_LABEL => {
361 ensure_entity_type(change, "label")?;
362 optional_workspace_payload(&change.payload)?;
363 required_string_payload("name", &change.payload)?;
364 required_string_payload("created_at", &change.payload)?;
365 }
366 op_type::CREATE_METADATA_FIELD => {
367 ensure_entity_type(change, "metadata_field")?;
368 ensure_metadata_field_id("entity_id", &change.entity_id)?;
369 required_workspace_payload(&change.payload)?;
370 validate_metadata_key_payload("key", &change.payload)?;
371 required_timestamp_payload("created_at", &change.payload)?;
372 }
373 op_type::SET_METADATA_FIELD => {
374 ensure_entity_type(change, "metadata_field")?;
375 ensure_metadata_field_id("entity_id", &change.entity_id)?;
376 required_workspace_payload(&change.payload)?;
377 if change.field.as_deref() != Some("key") {
378 bail!("error invalid-sync-change field=key");
379 }
380 validate_metadata_key_payload("key", &change.payload)?;
381 optional_bool_payload("conflict_resolution", &change.payload)?;
382 }
383 op_type::SET_TASK_METADATA | op_type::REMOVE_TASK_METADATA => {
384 ensure_entity_type(change, "task")?;
385 ensure_sync_id("entity_id", &change.entity_id)?;
386 required_workspace_payload(&change.payload)?;
387 let field_id = required_string_payload("field_id", &change.payload)?;
388 ensure_metadata_field_id("field_id", &field_id)?;
389 let expected_field = format!("metadata:{field_id}");
390 if change.field.as_deref() != Some(expected_field.as_str()) {
391 bail!("error invalid-sync-change metadata-field-mismatch");
392 }
393 validate_metadata_key_payload("key", &change.payload)?;
394 optional_bool_payload("conflict_resolution", &change.payload)?;
395 if change.op_type == op_type::SET_TASK_METADATA {
396 validate_metadata_value_payload("value", &change.payload)?;
397 }
398 }
399 op_type::CREATE_TASK => {
400 ensure_entity_type(change, "task")?;
401 ensure_sync_id("entity_id", &change.entity_id)?;
402 optional_workspace_payload(&change.payload)?;
403 required_string_payload("title", &change.payload)?;
404 let project_id = required_string_payload("project_id", &change.payload)?;
405 ensure_project_id("project_id", &project_id)?;
406 optional_string_payload("description", &change.payload)?;
407 let recurrence_task = change
408 .payload
409 .get("series_id")
410 .and_then(Value::as_str)
411 .is_some();
412 if recurrence_task {
413 let series_id = required_string_payload("series_id", &change.payload)?;
414 series_id.parse::<RecurrenceSeriesId>().map_err(|_| {
415 anyhow::anyhow!("error invalid-sync-change series_id invalid-id")
416 })?;
417 validate_recurrence_task(change)?;
418 } else {
419 required_string_payload("project_key", &change.payload)?;
420 required_string_payload("project_name", &change.payload)?;
421 required_string_payload("project_prefix", &change.payload)?;
422 }
423 if let Some(status) = optional_string_payload("status", &change.payload)? {
424 validate_sync_task_field_value(TaskField::Status, &status)?;
425 }
426 if let Some(priority) = optional_string_payload("priority", &change.payload)? {
427 validate_sync_task_field_value(TaskField::Priority, &priority)?;
428 }
429 if let Some(source) = optional_string_payload("source", &change.payload)? {
430 TaskSource::parse(&source)
431 .context("error invalid-sync-change invalid-task-source")?;
432 }
433 if let Some(available_at) = optional_string_payload("available_at", &change.payload)? {
434 validate_sync_task_field_value(TaskField::AvailableAt, &available_at)?;
435 }
436 if let Some(due_on) = optional_string_payload("due_on", &change.payload)? {
437 validate_sync_task_field_value(TaskField::DueOn, &due_on)?;
438 }
439 if let Some(is_epic) = optional_string_payload("is_epic", &change.payload)? {
440 validate_sync_task_field_value(TaskField::IsEpic, &is_epic)?;
441 }
442 optional_string_array_payload("labels", &change.payload)?;
443 validate_metadata_array_payload(&change.payload)?;
444 optional_string_payload("created_at", &change.payload)?;
445 }
446 op_type::SET_FIELD | op_type::RESOLVE_FIELD => {
447 ensure_entity_type(change, "task")?;
448 ensure_sync_id("entity_id", &change.entity_id)?;
449 optional_workspace_payload(&change.payload)?;
450 let field = change
451 .field
452 .as_deref()
453 .filter(|field| !field.trim().is_empty())
454 .context("error invalid-sync-change field missing")?;
455 let task_field = TaskField::parse_for_sync(field)?;
456 let value = required_string_payload("value", &change.payload)?;
457 validate_sync_task_field_value(task_field, &value)?;
458 if task_field == TaskField::Project {
459 let project_id = required_string_payload("project_id", &change.payload)?;
460 ensure_project_id("project_id", &project_id)?;
461 if value != project_id {
462 bail!("error invalid-sync-change project-value-mismatch");
463 }
464 required_string_payload("project_key", &change.payload)?;
465 required_string_payload("project_name", &change.payload)?;
466 required_string_payload("project_prefix", &change.payload)?;
467 }
468 }
469 op_type::LABEL_ADD | op_type::LABEL_REMOVE => {
470 ensure_entity_type(change, "task")?;
471 ensure_sync_id("entity_id", &change.entity_id)?;
472 optional_workspace_payload(&change.payload)?;
473 required_string_payload("label", &change.payload)?;
474 }
475 op_type::NOTE_ADD => {
476 ensure_entity_type(change, "task")?;
477 ensure_sync_id("entity_id", &change.entity_id)?;
478 optional_workspace_payload(&change.payload)?;
479 let note_id = required_string_payload("note_id", &change.payload)?;
480 ensure_sync_id("note_id", ¬e_id)?;
481 required_string_payload("body", &change.payload)?;
482 required_string_payload("created_at", &change.payload)?;
483 }
484 op_type::DEPENDENCY_ADD | op_type::DEPENDENCY_REMOVE => {
485 ensure_entity_type(change, "task")?;
486 ensure_sync_id("entity_id", &change.entity_id)?;
487 required_workspace_payload(&change.payload)?;
488 let depends_on_task_id =
489 required_string_payload("depends_on_task_id", &change.payload)?;
490 ensure_sync_id("depends_on_task_id", &depends_on_task_id)?;
491 if change.entity_id == depends_on_task_id {
492 bail!("error invalid-sync-change dependency-self");
493 }
494 }
495 op_type::EPIC_LINK_ADD | op_type::EPIC_LINK_REMOVE => {
496 ensure_entity_type(change, "task")?;
497 ensure_sync_id("entity_id", &change.entity_id)?;
498 required_workspace_payload(&change.payload)?;
499 let epic_task_id = required_string_payload("epic_task_id", &change.payload)?;
500 ensure_sync_id("epic_task_id", &epic_task_id)?;
501 if change.entity_id == epic_task_id {
502 bail!("error invalid-sync-change epic-self");
503 }
504 if change.op_type == op_type::EPIC_LINK_ADD {
505 required_timestamp_payload("created_at", &change.payload)?;
506 }
507 }
508 op_type::PROJECT_DELETE => {
509 ensure_entity_type(change, "project")?;
510 ensure_project_id("entity_id", &change.entity_id)?;
511 required_workspace_payload(&change.payload)?;
512 required_timestamp_payload("deleted_at", &change.payload)?;
513 }
514 op_type::SET_LABEL_NAME => {
515 ensure_entity_type(change, "label")?;
516 required_workspace_payload(&change.payload)?;
517 let name = required_string_payload("name", &change.payload)?;
518 if name != change.entity_id {
519 bail!("error invalid-sync-change label-value-mismatch");
520 }
521 required_string_payload("new_name", &change.payload)?;
522 required_timestamp_payload("renamed_at", &change.payload)?;
523 }
524 op_type::LABEL_DELETE => {
525 ensure_entity_type(change, "label")?;
526 required_workspace_payload(&change.payload)?;
527 let name = required_string_payload("name", &change.payload)?;
528 if name != change.entity_id {
529 bail!("error invalid-sync-change label-value-mismatch");
530 }
531 required_timestamp_payload("deleted_at", &change.payload)?;
532 }
533 op_type::NOTE_EDIT => {
534 ensure_entity_type(change, "task")?;
535 ensure_sync_id("entity_id", &change.entity_id)?;
536 if change.field.as_deref() != Some("notes") {
537 bail!("error invalid-sync-change field=notes");
538 }
539 required_workspace_payload(&change.payload)?;
540 let note_id = required_string_payload("note_id", &change.payload)?;
541 ensure_sync_id("note_id", ¬e_id)?;
542 required_string_payload("body", &change.payload)?;
543 required_timestamp_payload("edited_at", &change.payload)?;
544 }
545 op_type::LABEL_RESTORE => {
546 ensure_entity_type(change, "label")?;
547 required_workspace_payload(&change.payload)?;
548 let name = required_string_payload("name", &change.payload)?;
549 if name != change.entity_id {
550 bail!("error invalid-sync-change label-value-mismatch");
551 }
552 required_timestamp_payload("created_at", &change.payload)?;
553 required_timestamp_payload("restored_at", &change.payload)?;
554 for key in ["task_ids", "series_ids"] {
555 optional_string_array_payload(key, &change.payload)?;
556 if change.payload.get(key).is_none() {
557 bail!("error invalid-sync-change payload.{key} missing");
558 }
559 }
560 for task_id in string_array_payload("task_ids", &change.payload)? {
561 ensure_sync_id("task_ids", &task_id)?;
562 }
563 for series_id in string_array_payload("series_ids", &change.payload)? {
564 ensure_sync_id("series_ids", &series_id)?;
565 }
566 }
567 op_type::NOTE_DELETE => {
568 ensure_entity_type(change, "task")?;
569 ensure_sync_id("entity_id", &change.entity_id)?;
570 if change.field.as_deref() != Some("notes") {
571 bail!("error invalid-sync-change field=notes");
572 }
573 required_workspace_payload(&change.payload)?;
574 let note_id = required_string_payload("note_id", &change.payload)?;
575 ensure_sync_id("note_id", ¬e_id)?;
576 required_timestamp_payload("deleted_at", &change.payload)?;
577 }
578 op_type::SET_RECURRENCE_METADATA | op_type::REMOVE_RECURRENCE_METADATA => {
579 ensure_entity_type(change, "recurrence_series")?;
580 change
581 .entity_id
582 .parse::<RecurrenceSeriesId>()
583 .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
584 required_workspace_payload(&change.payload)?;
585 let field_id = required_string_payload("field_id", &change.payload)?;
586 ensure_metadata_field_id("field_id", &field_id)?;
587 let expected_field = format!("metadata:{field_id}");
588 if change.field.as_deref() != Some(expected_field.as_str()) {
589 bail!("error invalid-sync-change metadata-field-mismatch");
590 }
591 validate_metadata_key_payload("key", &change.payload)?;
592 optional_bool_payload("conflict_resolution", &change.payload)?;
593 if change.op_type == op_type::SET_RECURRENCE_METADATA {
594 validate_metadata_value_payload("value", &change.payload)?;
595 }
596 }
597 op_type::CREATE_RECURRENCE_SERIES => validate_recurrence_create(change)?,
598 op_type::UPDATE_RECURRENCE_TEMPLATE => validate_recurrence_template(change)?,
599 op_type::PROJECT_RECURRENCE_OCCURRENCE => validate_recurrence_projection(change)?,
600 op_type::RESOLVE_RECURRENCE_OCCURRENCE => validate_recurrence_outcome(change)?,
601 op_type::SET_RECURRENCE_STATE | op_type::STOP_RECURRENCE_SERIES => {
602 validate_recurrence_state(change)?
603 }
604 op_type::OPEN_RECURRENCE_PAUSE => validate_recurrence_pause(change, true)?,
605 op_type::CLOSE_RECURRENCE_PAUSE => validate_recurrence_pause(change, false)?,
606 op_type::ATTACHMENT_ADD => validate_attachment_add_change(change)?,
607 op_type::ATTACHMENT_DELETE => validate_attachment_delete_change(change)?,
608 _ => bail!("error invalid-sync-change op_type={}", change.op_type),
609 }
610 Ok(())
611}
612
613fn validate_recurrence_task(change: &ChangeWire) -> Result<()> {
614 let workspace_id: WorkspaceId = required_string_payload("workspace_id", &change.payload)?
615 .parse()
616 .map_err(|_| anyhow::anyhow!("error invalid-sync-change workspace_id invalid-id"))?;
617 let series_id: RecurrenceSeriesId = required_string_payload("series_id", &change.payload)?
618 .parse()
619 .map_err(|_| anyhow::anyhow!("error invalid-sync-change series_id invalid-id"))?;
620 let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
621 let schedule = recurrence_schedule_payload(&change.payload)?;
622 if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
623 bail!("error invalid-sync-change recurrence-slot-off-lattice");
624 }
625 let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)
626 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
627 let slot = crate::recurrence::slot_values(&schedule, slot_on)
628 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
629 for (key, expected) in [
630 ("task_id", identity.task_id.as_str()),
631 ("created_at", identity.created_at.as_str()),
632 ("updated_at", identity.updated_at.as_str()),
633 ("available_at", slot.available_at.as_str()),
634 ("due_on", slot.due_on.as_deref().unwrap_or("")),
635 ("task_change_id", identity.task_change_id.as_str()),
636 (
637 "occurrence_change_id",
638 identity.occurrence_change_id.as_str(),
639 ),
640 (
641 "task_field_version_seed",
642 identity.field_version_seeds.task.as_str(),
643 ),
644 (
645 "occurrence_field_version_seed",
646 identity.field_version_seeds.occurrence.as_str(),
647 ),
648 ] {
649 if required_string_payload(key, &change.payload)? != expected {
650 bail!("error invalid-sync-change recurrence-deterministic-mismatch field={key}");
651 }
652 }
653 if change.entity_id != identity.task_id.as_str()
654 || change.change_id != identity.task_change_id
655 || change.created_at != identity.created_at
656 {
657 bail!("error invalid-sync-change recurrence-deterministic-mismatch field=change_identity");
658 }
659 Ok(())
660}
661
662fn validate_recurrence_create(change: &ChangeWire) -> Result<()> {
663 ensure_entity_type(change, "recurrence_series")?;
664 let series_id: RecurrenceSeriesId = change
665 .entity_id
666 .parse()
667 .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
668 required_workspace_payload(&change.payload)?;
669 if required_string_payload("series_id", &change.payload)? != series_id.as_str() {
670 bail!("error invalid-sync-change recurrence-series-id-mismatch");
671 }
672 required_string_payload("title", &change.payload)?;
673 required_string_payload("description", &change.payload)?;
674 let project_id = required_string_payload("project_id", &change.payload)?;
675 ensure_project_id("project_id", &project_id)?;
676 required_string_payload("project_key", &change.payload)?;
677 required_string_payload("project_name", &change.payload)?;
678 required_string_payload("project_prefix", &change.payload)?;
679 validate_sync_task_field_value(
680 TaskField::Priority,
681 &required_string_payload("priority", &change.payload)?,
682 )?;
683 let status = required_string_payload("initial_status", &change.payload)?;
684 validate_sync_task_field_value(TaskField::Status, &status)?;
685 if matches!(status.as_str(), "done" | "canceled") {
686 bail!("error invalid-sync-change recurrence-terminal-template");
687 }
688 recurrence_schedule_payload(&change.payload)?;
689 optional_string_array_payload("labels", &change.payload)?;
690 validate_metadata_array_payload(&change.payload)?;
691 if required_string_payload("state", &change.payload)? != "active"
692 || !required_string_payload("stopped_at", &change.payload)?.is_empty()
693 {
694 bail!("error invalid-sync-change recurrence-create-state");
695 }
696 required_timestamp_payload("created_at", &change.payload)?;
697 required_timestamp_payload("updated_at", &change.payload)?;
698 Ok(())
699}
700
701fn validate_recurrence_template(change: &ChangeWire) -> Result<()> {
702 validate_recurrence_entity(change)?;
703 let fields = change
704 .payload
705 .get("fields")
706 .and_then(Value::as_array)
707 .context("error invalid-sync-change payload.fields missing")?;
708 if fields.len() > 8 {
709 bail!("error invalid-sync-change recurrence-template-fields-too-large");
710 }
711 let base_versions = change
712 .payload
713 .get("base_versions")
714 .and_then(Value::as_object)
715 .context("error invalid-sync-change payload.base_versions missing")?;
716 let mut seen = HashSet::new();
717 for pair in fields {
718 let pair = pair
719 .as_array()
720 .filter(|pair| pair.len() == 2)
721 .context("error invalid-sync-change recurrence-template-field-pair")?;
722 let field = pair[0]
723 .as_str()
724 .context("error invalid-sync-change recurrence-template-field")?;
725 let value = pair[1]
726 .as_str()
727 .context("error invalid-sync-change recurrence-template-value")?;
728 if !seen.insert(field) {
729 bail!("error invalid-sync-change recurrence-template-duplicate-field");
730 }
731 if !matches!(
732 base_versions.get(field),
733 Some(Value::String(_)) | Some(Value::Null)
734 ) {
735 bail!("error invalid-sync-change recurrence-template-base-version field={field}");
736 }
737 match field {
738 "title" | "description" => {}
739 "project" => ensure_project_id("project_id", value)?,
740 "priority" => validate_sync_task_field_value(TaskField::Priority, value)?,
741 "initial_status" => {
742 validate_sync_task_field_value(TaskField::Status, value)?;
743 if matches!(value, "done" | "canceled") {
744 bail!("error invalid-sync-change recurrence-terminal-template");
745 }
746 }
747 "available_local_time" => {
748 validate_local_time(value)?;
749 }
750 "due_policy" => {
751 RecurrenceDuePolicy::parse(value)
752 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
753 }
754 _ => bail!("error invalid-sync-change recurrence-template-field={field}"),
755 }
756 }
757 optional_string_array_payload("labels", &change.payload)?;
758 let labels_changed = change
759 .payload
760 .get("labels_changed")
761 .and_then(Value::as_bool)
762 .context("error invalid-sync-change payload.labels_changed missing")?;
763 if labels_changed
764 && !matches!(
765 base_versions.get("labels"),
766 Some(Value::String(_)) | Some(Value::Null)
767 )
768 {
769 bail!("error invalid-sync-change recurrence-template-base-version field=labels");
770 }
771 required_timestamp_payload("updated_at", &change.payload)?;
772 Ok(())
773}
774
775fn validate_recurrence_projection(change: &ChangeWire) -> Result<()> {
776 validate_recurrence_entity(change)?;
777 if change.field.as_deref() != Some("projection") {
778 bail!("error invalid-sync-change field=projection");
779 }
780 let workspace_id: WorkspaceId = required_string_payload("workspace_id", &change.payload)?
781 .parse()
782 .map_err(|_| anyhow::anyhow!("error invalid-sync-change workspace_id invalid-id"))?;
783 let series_id: RecurrenceSeriesId = change.entity_id.parse().unwrap();
784 let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
785 let schedule = recurrence_schedule_payload(&change.payload)?;
786 if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
787 bail!("error invalid-sync-change recurrence-slot-off-lattice");
788 }
789 let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)
790 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
791 for (key, expected) in [
792 ("task_id", identity.task_id.as_str()),
793 (
794 "projected_at",
795 identity.occurrence_link.projected_at.as_str(),
796 ),
797 ("task_change_id", identity.task_change_id.as_str()),
798 (
799 "occurrence_change_id",
800 identity.occurrence_change_id.as_str(),
801 ),
802 (
803 "task_field_version_seed",
804 identity.field_version_seeds.task.as_str(),
805 ),
806 (
807 "occurrence_field_version_seed",
808 identity.field_version_seeds.occurrence.as_str(),
809 ),
810 ] {
811 if required_string_payload(key, &change.payload)? != expected {
812 bail!("error invalid-sync-change recurrence-deterministic-mismatch field={key}");
813 }
814 }
815 if change.change_id != identity.occurrence_change_id
816 || change.created_at != identity.occurrence_link.projected_at
817 {
818 bail!("error invalid-sync-change recurrence-deterministic-mismatch field=change_id");
819 }
820 Ok(())
821}
822
823fn validate_recurrence_outcome(change: &ChangeWire) -> Result<()> {
824 validate_recurrence_entity(change)?;
825 validate_timestamp_value("created_at", &change.created_at)?;
826 if change.field.as_deref() != Some("outcome") {
827 bail!("error invalid-sync-change field=outcome");
828 }
829 let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
830 let schedule = recurrence_schedule_payload(&change.payload)?;
831 if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
832 bail!("error invalid-sync-change recurrence-slot-off-lattice");
833 }
834 let outcome = RecurrenceOutcome::parse(&required_string_payload("outcome", &change.payload)?)
835 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
836 let resolved_at = required_timestamp_payload("resolved_at", &change.payload)?;
837 if resolved_at < crate::recurrence::slot_values(&schedule, slot_on)?.boundary_at {
838 bail!("error invalid-sync-change recurrence-resolution-before-slot");
839 }
840 let conflict_resolution = change
841 .payload
842 .get("conflict_resolution")
843 .and_then(Value::as_bool)
844 .unwrap_or(false);
845 let task_id = required_string_payload("task_id", &change.payload)?;
846 ensure_sync_id("task_id", &task_id)?;
847 let workspace_id: WorkspaceId =
848 required_string_payload("workspace_id", &change.payload)?.parse()?;
849 let series_id: RecurrenceSeriesId = change.entity_id.parse().unwrap();
850 let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)?;
851 if task_id != identity.task_id.as_str() {
852 bail!("error invalid-sync-change recurrence-deterministic-mismatch field=task_id");
853 }
854 let expected_status = match outcome {
855 RecurrenceOutcome::Completed => "done",
856 RecurrenceOutcome::Skipped => "canceled",
857 };
858 if required_string_payload("task_status", &change.payload)? != expected_status {
859 bail!("error invalid-sync-change recurrence-outcome-status-mismatch");
860 }
861 let status_change_id = required_string_payload("task_status_change_id", &change.payload)?;
862 if !conflict_resolution || !status_change_id.is_empty() {
863 ensure_sync_id("task_status_change_id", &status_change_id)?;
864 }
865 let successor = required_string_payload("successor_task_id", &change.payload)?;
866 if !successor.is_empty() {
867 ensure_sync_id("successor_task_id", &successor)?;
868 let successor_slot = next_slot_after(&schedule.rule, schedule.start_on, slot_on)
869 .context("error invalid-sync-change recurrence-successor-out-of-range")?;
870 let successor_identity =
871 derive_occurrence_identity(&workspace_id, &series_id, &schedule, successor_slot)?;
872 if successor != successor_identity.task_id.as_str() {
873 bail!(
874 "error invalid-sync-change recurrence-deterministic-mismatch field=successor_task_id"
875 );
876 }
877 }
878 Ok(())
879}
880
881fn validate_recurrence_state(change: &ChangeWire) -> Result<()> {
882 validate_recurrence_entity(change)?;
883 if change.field.as_deref() != Some("state") {
884 bail!("error invalid-sync-change field=state");
885 }
886 let conflict_resolution = change
887 .payload
888 .get("conflict_resolution")
889 .and_then(Value::as_bool)
890 .unwrap_or(false);
891 if change.base_version.is_none() && !conflict_resolution {
892 bail!("error invalid-sync-change recurrence-lifecycle-base-version-missing");
893 }
894 let state = RecurrenceSeriesState::parse(&required_string_payload("state", &change.payload)?)
895 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
896 let stopped_at = required_string_payload("stopped_at", &change.payload)?;
897 if matches!(state, RecurrenceSeriesState::Stopped) {
898 required_timestamp_payload("stopped_at", &change.payload)?;
899 } else if !stopped_at.is_empty() {
900 bail!("error invalid-sync-change recurrence-stop-state-mismatch");
901 }
902 required_timestamp_payload("changed_at", &change.payload)?;
903 Ok(())
904}
905
906fn validate_recurrence_pause(change: &ChangeWire, open: bool) -> Result<()> {
907 validate_recurrence_entity(change)?;
908 if change.field.as_deref() != Some("pause") {
909 bail!("error invalid-sync-change field=pause");
910 }
911 if open {
912 let interval_id = required_string_payload("interval_id", &change.payload)?;
913 ensure_sync_id("interval_id", &interval_id)?;
914 required_timestamp_payload("paused_at", &change.payload)?;
915 let slot = required_string_payload("suspended_slot_on", &change.payload)?;
916 if !slot.is_empty() {
917 slot.parse::<NaiveDate>()
918 .context("error invalid-sync-change suspended_slot_on")?;
919 let task_id = required_string_payload("suspended_task_id", &change.payload)?;
920 ensure_sync_id("suspended_task_id", &task_id)?;
921 }
922 } else {
923 required_timestamp_payload("resumed_at", &change.payload)?;
924 if let Some(interval_id) = optional_string_payload("interval_id", &change.payload)? {
925 ensure_sync_id("interval_id", &interval_id)?;
926 }
927 }
928 Ok(())
929}
930
931fn validate_recurrence_entity(change: &ChangeWire) -> Result<()> {
932 ensure_entity_type(change, "recurrence_series")?;
933 change
934 .entity_id
935 .parse::<RecurrenceSeriesId>()
936 .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
937 required_workspace_payload(&change.payload)?;
938 Ok(())
939}
940
941fn recurrence_schedule_payload(payload: &Value) -> Result<RecurrenceSchedule> {
942 let frequency = RecurrenceFrequency::parse(&required_string_payload("frequency", payload)?)
943 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
944 let interval = required_i64_payload("interval", payload)?;
945 let interval = u32::try_from(interval)
946 .context("error invalid-sync-change recurrence-interval-out-of-range")?;
947 let weekdays_text = required_string_payload("weekdays", payload)?;
948 let weekdays = weekdays_text
949 .parse::<WeekdaySet>()
950 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
951 if weekdays.to_string() != weekdays_text {
952 bail!("error invalid-sync-change recurrence-weekdays-noncanonical");
953 }
954 let rule = RecurrenceRule::new(frequency, interval, weekdays)
955 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
956 let timezone_text = required_string_payload("timezone", payload)?;
957 let timezone = timezone_text
958 .parse::<TimeZoneId>()
959 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
960 if timezone_text.parse::<chrono_tz::Tz>()?.to_string() != timezone_text {
961 bail!("error invalid-sync-change recurrence-timezone-noncanonical");
962 }
963 let start_on = recurrence_date_payload("start_on", payload)?;
964 let available = required_string_payload("available_local_time", payload)?;
965 let available_local_time = if available.is_empty() {
966 None
967 } else {
968 Some(validate_local_time(&available)?)
969 };
970 let due_policy = RecurrenceDuePolicy::parse(&required_string_payload("due_policy", payload)?)
971 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
972 Ok(RecurrenceSchedule::new(
973 rule,
974 timezone,
975 start_on,
976 available_local_time,
977 due_policy,
978 ))
979}
980
981fn recurrence_date_payload(key: &str, payload: &Value) -> Result<NaiveDate> {
982 required_string_payload(key, payload)?
983 .parse()
984 .with_context(|| format!("error invalid-sync-change payload.{key} invalid-date"))
985}
986
987fn validate_local_time(value: &str) -> Result<NaiveTime> {
988 NaiveTime::parse_from_str(value, "%H:%M:%S")
989 .context("error invalid-sync-change recurrence-local-time")
990}
991
992fn validate_attachment_add_change(change: &ChangeWire) -> Result<()> {
993 ensure_entity_type(change, "task")?;
994 ensure_sync_id("entity_id", &change.entity_id)?;
995 ensure_attachment_field(change)?;
996 required_workspace_payload(&change.payload)?;
997 let attachment_id = required_string_payload("attachment_id", &change.payload)?;
998 ensure_sync_id("attachment_id", &attachment_id)?;
999 let sha256 = required_string_payload("sha256", &change.payload)?;
1000 validate_sha256_for_sync(&sha256)?;
1001 let byte_size = required_i64_payload("byte_size", &change.payload)?;
1002 validate_blob_size_for_sync(byte_size)?;
1003 let media_type = required_string_payload("media_type", &change.payload)?;
1004 map_attachment_validation(validate_media_type(&media_type))?;
1005 let filename = optional_string_payload("filename", &change.payload)?;
1006 map_attachment_validation(validate_filename(filename.as_deref()))?;
1007 let alt_text = optional_string_payload("alt_text", &change.payload)?;
1008 map_attachment_validation(validate_alt_text(alt_text.as_deref()))?;
1009 let width = optional_i64_payload("width", &change.payload)?;
1010 let height = optional_i64_payload("height", &change.payload)?;
1011 map_attachment_validation(validate_dimensions(width, height))?;
1012 required_timestamp_payload("created_at", &change.payload)?;
1013 Ok(())
1014}
1015
1016fn validate_attachment_delete_change(change: &ChangeWire) -> Result<()> {
1017 ensure_entity_type(change, "task")?;
1018 ensure_sync_id("entity_id", &change.entity_id)?;
1019 ensure_attachment_field(change)?;
1020 required_workspace_payload(&change.payload)?;
1021 let attachment_id = required_string_payload("attachment_id", &change.payload)?;
1022 ensure_sync_id("attachment_id", &attachment_id)?;
1023 required_timestamp_payload("deleted_at", &change.payload)?;
1024 Ok(())
1025}
1026
1027fn ensure_attachment_field(change: &ChangeWire) -> Result<()> {
1028 if change.field.as_deref() != Some("attachments") {
1029 bail!("error invalid-sync-change field=attachments");
1030 }
1031 Ok(())
1032}
1033
1034fn validate_sha256_for_sync(value: &str) -> Result<()> {
1035 if value.len() == 64
1036 && value
1037 .bytes()
1038 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1039 {
1040 Ok(())
1041 } else {
1042 bail!("error invalid-sync-change invalid-sha256");
1043 }
1044}
1045
1046fn validate_blob_size_for_sync(byte_size: i64) -> Result<()> {
1047 let Ok(bytes) = usize::try_from(byte_size) else {
1048 bail!("error invalid-sync-change error invalid-attachment-size bytes={byte_size}");
1049 };
1050 if bytes == 0 || bytes > MAX_BLOB_BYTES {
1051 bail!("error invalid-sync-change error invalid-attachment-size bytes={byte_size}");
1052 }
1053 map_attachment_validation(validate_blob_size(bytes))
1054}
1055
1056fn map_attachment_validation(result: Result<()>) -> Result<()> {
1057 result.map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))
1058}
1059
1060fn validate_change_server_seq(change: &ChangeWire, direction: ChangeDirection) -> Result<()> {
1061 match direction {
1062 ChangeDirection::Pushed if change.server_seq.is_some() => {
1063 bail!("error invalid-sync-change server_seq client-supplied");
1064 }
1065 ChangeDirection::Pulled => match change.server_seq {
1066 Some(server_seq) if server_seq > 0 => {}
1067 Some(server_seq) => {
1068 bail!("error invalid-sync-change server_seq={server_seq}");
1069 }
1070 None => bail!("error invalid-sync-change server_seq missing"),
1071 },
1072 ChangeDirection::Pushed => {}
1073 }
1074 Ok(())
1075}
1076
1077fn validate_push_acks(request_change_ids: &[String], response: &SyncResponse) -> Result<()> {
1078 if response.push_acks.len() != request_change_ids.len() {
1079 bail!(
1080 "error invalid-sync-response push-ack-count expected={} got={}",
1081 request_change_ids.len(),
1082 response.push_acks.len()
1083 );
1084 }
1085 let expected = request_change_ids
1086 .iter()
1087 .map(String::as_str)
1088 .collect::<HashSet<_>>();
1089 let mut seen = HashSet::with_capacity(response.push_acks.len());
1090 for ack in &response.push_acks {
1091 if !expected.contains(ack.change_id.as_str()) {
1092 bail!(
1093 "error invalid-sync-response unexpected-push-ack change_id={}",
1094 ack.change_id
1095 );
1096 }
1097 if ack.server_seq <= 0 {
1098 bail!(
1099 "error invalid-sync-response push-ack-server-seq change_id={} server_seq={}",
1100 ack.change_id,
1101 ack.server_seq
1102 );
1103 }
1104 if !seen.insert(ack.change_id.as_str()) {
1105 bail!(
1106 "error invalid-sync-response duplicate-push-ack change_id={}",
1107 ack.change_id
1108 );
1109 }
1110 }
1111 Ok(())
1112}
1113
1114fn validate_pull_page(after: i64, pull_limit: u32, response: &SyncResponse) -> Result<()> {
1115 let mut previous = after;
1116 let mut change_ids = HashSet::with_capacity(response.changes.len());
1117 for change in &response.changes {
1118 validate_pulled_change(change)?;
1119 if !change_ids.insert(&change.change_id) {
1120 bail!(
1121 "error invalid-sync-response duplicate-pull-change change_id={}",
1122 change.change_id
1123 );
1124 }
1125 let server_seq = change.server_seq.with_context(|| {
1126 format!(
1127 "error invalid-sync-response missing-server-seq change_id={}",
1128 change.change_id
1129 )
1130 })?;
1131 if server_seq <= previous {
1132 bail!(
1133 "error invalid-sync-response server-seq-order previous={} server_seq={}",
1134 previous,
1135 server_seq
1136 );
1137 }
1138 previous = server_seq;
1139 }
1140 let expected_cursor = response
1141 .changes
1142 .last()
1143 .and_then(|change| change.server_seq)
1144 .unwrap_or(after);
1145 if response.cursor != expected_cursor {
1146 bail!(
1147 "error invalid-sync-response cursor-mismatch expected={} got={}",
1148 expected_cursor,
1149 response.cursor
1150 );
1151 }
1152 if response.has_more && response.changes.len() < pull_limit as usize {
1153 bail!(
1154 "error invalid-sync-response has-more-short-page returned={} limit={}",
1155 response.changes.len(),
1156 pull_limit
1157 );
1158 }
1159 Ok(())
1160}
1161
1162fn validate_push_pull_overlap(response: &SyncResponse) -> Result<()> {
1163 let acked = response
1164 .push_acks
1165 .iter()
1166 .map(|ack| (ack.change_id.as_str(), ack.server_seq))
1167 .collect::<HashMap<_, _>>();
1168 for change in &response.changes {
1169 if let Some(acked_server_seq) = acked.get(change.change_id.as_str()) {
1170 let Some(pull_server_seq) = change.server_seq else {
1171 continue;
1172 };
1173 if *acked_server_seq != pull_server_seq {
1174 bail!(
1175 "error invalid-sync-response push-pull-server-seq-mismatch change_id={} ack={} pull={}",
1176 change.change_id,
1177 acked_server_seq,
1178 pull_server_seq
1179 );
1180 }
1181 }
1182 }
1183 Ok(())
1184}
1185
1186fn validate_sync_task_field_value(field: TaskField, value: &str) -> Result<()> {
1187 field
1188 .validate_value(value)
1189 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))
1190}
1191
1192fn ensure_entity_type(change: &ChangeWire, expected: &str) -> Result<()> {
1193 if change.entity_type == expected {
1194 Ok(())
1195 } else {
1196 bail!(
1197 "error invalid-sync-change op_type={} entity_type={} expected={}",
1198 change.op_type,
1199 change.entity_type,
1200 expected
1201 )
1202 }
1203}
1204
1205fn ensure_non_empty(name: &str, value: &str) -> Result<()> {
1206 if value.trim().is_empty() {
1207 bail!("error invalid-sync-change {name} empty");
1208 }
1209 Ok(())
1210}
1211
1212fn ensure_sync_id(name: &str, value: &str) -> Result<()> {
1213 if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
1214 Ok(())
1215 } else {
1216 bail!("error invalid-sync-change {name} invalid-id");
1217 }
1218}
1219
1220fn ensure_project_id(name: &str, value: &str) -> Result<()> {
1221 if value.parse::<ProjectId>().is_ok() {
1222 Ok(())
1223 } else {
1224 bail!("error invalid-sync-change {name} invalid-id");
1225 }
1226}
1227
1228fn required_string_payload(key: &str, payload: &Value) -> Result<String> {
1229 payload
1230 .get(key)
1231 .and_then(Value::as_str)
1232 .map(str::to_string)
1233 .with_context(|| format!("error invalid-sync-change payload.{key} missing"))
1234}
1235
1236fn required_i64_payload(key: &str, payload: &Value) -> Result<i64> {
1237 match payload.get(key) {
1238 Some(Value::Number(value)) => value
1239 .as_i64()
1240 .with_context(|| format!("error invalid-sync-change payload.{key} invalid")),
1241 Some(Value::Null) | None => bail!("error invalid-sync-change payload.{key} missing"),
1242 Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1243 }
1244}
1245
1246fn required_timestamp_payload(key: &str, payload: &Value) -> Result<String> {
1247 let value = required_string_payload(key, payload)?;
1248 validate_timestamp_value(&format!("payload.{key}"), &value)?;
1249 Ok(value)
1250}
1251
1252fn validate_timestamp_value(label: &str, value: &str) -> Result<()> {
1253 if value.ends_with('Z') && chrono::DateTime::parse_from_rfc3339(value).is_ok() {
1254 Ok(())
1255 } else {
1256 bail!("error invalid-sync-change {label} invalid-timestamp");
1257 }
1258}
1259
1260fn required_workspace_payload(payload: &Value) -> Result<()> {
1261 let id = required_string_payload("workspace_id", payload)?;
1262 if id.parse::<WorkspaceId>().is_err() {
1263 bail!("error invalid-sync-change workspace_id invalid-id");
1264 }
1265 required_string_payload("workspace_key", payload)?;
1266 Ok(())
1267}
1268
1269fn optional_workspace_payload(payload: &Value) -> Result<()> {
1270 if payload.get("workspace_id").is_none() && payload.get("workspace_key").is_none() {
1271 return Ok(());
1272 }
1273 required_workspace_payload(payload)
1274}
1275
1276fn optional_string_payload(key: &str, payload: &Value) -> Result<Option<String>> {
1277 match payload.get(key) {
1278 Some(Value::String(value)) => Ok(Some(value.clone())),
1279 Some(Value::Null) | None => Ok(None),
1280 Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1281 }
1282}
1283
1284fn optional_i64_payload(key: &str, payload: &Value) -> Result<Option<i64>> {
1285 match payload.get(key) {
1286 Some(Value::Number(value)) => value
1287 .as_i64()
1288 .map(Some)
1289 .with_context(|| format!("error invalid-sync-change payload.{key} invalid")),
1290 Some(Value::Null) | None => Ok(None),
1291 Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1292 }
1293}
1294
1295fn optional_bool_payload(key: &str, payload: &Value) -> Result<Option<bool>> {
1296 match payload.get(key) {
1297 Some(Value::Bool(value)) => Ok(Some(*value)),
1298 Some(Value::Null) | None => Ok(None),
1299 Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1300 }
1301}
1302
1303fn string_array_payload(key: &str, payload: &Value) -> Result<Vec<String>> {
1304 payload
1305 .get(key)
1306 .and_then(Value::as_array)
1307 .with_context(|| format!("error invalid-sync-change payload.{key} invalid"))?
1308 .iter()
1309 .map(|value| {
1310 value
1311 .as_str()
1312 .map(str::to_string)
1313 .with_context(|| format!("error invalid-sync-change payload.{key} invalid"))
1314 })
1315 .collect()
1316}
1317
1318fn optional_string_array_payload(key: &str, payload: &Value) -> Result<()> {
1319 match payload.get(key) {
1320 Some(Value::Array(values))
1321 if values
1322 .iter()
1323 .all(|value| value.as_str().is_some_and(|value| !value.trim().is_empty())) =>
1324 {
1325 Ok(())
1326 }
1327 Some(Value::Null) | None => Ok(()),
1328 Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1329 }
1330}
1331
1332fn ensure_metadata_field_id(name: &str, value: &str) -> Result<()> {
1333 value
1334 .parse::<MetadataFieldId>()
1335 .map(|_| ())
1336 .map_err(|_| anyhow::anyhow!("error invalid-sync-change {name} invalid-id"))
1337}
1338
1339fn validate_metadata_key_payload(key: &str, payload: &Value) -> Result<String> {
1340 let value = required_string_payload(key, payload)?;
1341 let normalized = crate::metadata::normalize_metadata_key(&value)
1342 .map_err(|_| anyhow::anyhow!("error invalid-sync-change payload.{key} invalid"))?;
1343 if normalized != value {
1344 bail!("error invalid-sync-change payload.{key} noncanonical");
1345 }
1346 Ok(value)
1347}
1348
1349fn validate_metadata_value_payload(key: &str, payload: &Value) -> Result<String> {
1350 let value = required_string_payload(key, payload)?;
1351 if value.len() > crate::metadata::MAX_METADATA_VALUE_BYTES {
1352 bail!("error invalid-sync-change payload.{key} too-large");
1353 }
1354 Ok(value)
1355}
1356
1357fn validate_metadata_array_payload(payload: &Value) -> Result<()> {
1358 let Some(values) = payload.get("metadata") else {
1359 return Ok(());
1360 };
1361 let values = values
1362 .as_array()
1363 .context("error invalid-sync-change payload.metadata invalid")?;
1364 if values.len() > crate::metadata::MAX_METADATA_VALUES {
1365 bail!("error invalid-sync-change payload.metadata too-many");
1366 }
1367 let mut ids = HashSet::new();
1368 let mut keys = HashSet::new();
1369 let mut total_bytes = 0usize;
1370 for value in values {
1371 let object = value
1372 .as_object()
1373 .context("error invalid-sync-change payload.metadata invalid")?;
1374 let field_id = required_string_payload("field_id", value)?;
1375 ensure_metadata_field_id("metadata.field_id", &field_id)?;
1376 let key = validate_metadata_key_payload("key", value)?;
1377 let metadata_value = validate_metadata_value_payload("value", value)?;
1378 if !ids.insert(field_id) || !keys.insert(key) {
1379 bail!("error invalid-sync-change payload.metadata duplicate");
1380 }
1381 if object.len() != 3 {
1382 bail!("error invalid-sync-change payload.metadata invalid");
1383 }
1384 total_bytes = total_bytes.saturating_add(metadata_value.len());
1385 }
1386 if total_bytes > crate::metadata::MAX_METADATA_TOTAL_BYTES {
1387 bail!("error invalid-sync-change payload.metadata too-large");
1388 }
1389 Ok(())
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394 use super::*;
1395 use crate::change_log::{ChangePayload, op_type};
1396 use crate::workspaces::Workspace;
1397
1398 fn test_workspace() -> Workspace {
1399 Workspace {
1400 id: "0000000000000000".parse().unwrap(),
1401 key: "default".to_string(),
1402 name: "default".to_string(),
1403 }
1404 }
1405
1406 fn make_change_wire(
1407 op_type: &str,
1408 entity_type: &str,
1409 entity_id: &str,
1410 payload: serde_json::Value,
1411 ) -> ChangeWire {
1412 ChangeWire {
1413 change_id: "AAAAAAAAAAAAAAA0".to_string(),
1414 client_id: "client".to_string(),
1415 local_seq: 1,
1416 entity_type: entity_type.to_string(),
1417 entity_id: entity_id.to_string(),
1418 field: None,
1419 op_type: op_type.to_string(),
1420 payload,
1421 base_version: None,
1422 created_at: "2026-06-01T00:00:00Z".to_string(),
1423 server_seq: None,
1424 }
1425 }
1426
1427 #[test]
1428 fn sync_timestamps_accept_fractional_utc_precision() {
1429 validate_timestamp_value("created_at", "2026-07-28T06:41:44Z").unwrap();
1430 validate_timestamp_value("created_at", "2026-07-28T06:41:44.000000Z").unwrap();
1431 validate_timestamp_value("created_at", "2026-07-28T06:41:44.123456789Z").unwrap();
1432 }
1433
1434 #[test]
1435 fn sync_timestamps_reject_offsets_and_invalid_calendar_values() {
1436 for value in ["2026-07-28T08:41:44+02:00", "2026-13-28T06:41:44Z", "today"] {
1437 assert!(validate_timestamp_value("created_at", value).is_err());
1438 }
1439 }
1440
1441 #[test]
1442 fn recurrence_projection_rejects_nondeterministic_change_timestamp() {
1443 let workspace = test_workspace();
1444 let series_id: RecurrenceSeriesId = "AAAAAAAAAAAAAAAA".parse().unwrap();
1445 let schedule = RecurrenceSchedule::new(
1446 RecurrenceRule::daily(),
1447 "UTC".parse().unwrap(),
1448 "2026-07-20".parse().unwrap(),
1449 None,
1450 RecurrenceDuePolicy::SameDay,
1451 );
1452 let slot_on: NaiveDate = "2026-07-20".parse().unwrap();
1453 let identity =
1454 derive_occurrence_identity(&workspace.id, &series_id, &schedule, slot_on).unwrap();
1455 let payload = ChangePayload::workspace(&workspace)
1456 .set("series_id", series_id.as_str())
1457 .set("slot_on", slot_on.to_string())
1458 .set("task_id", identity.task_id.as_str())
1459 .set("projected_at", &identity.occurrence_link.projected_at)
1460 .set("task_change_id", &identity.task_change_id)
1461 .set("occurrence_change_id", &identity.occurrence_change_id)
1462 .set(
1463 "task_field_version_seed",
1464 &identity.field_version_seeds.task,
1465 )
1466 .set(
1467 "occurrence_field_version_seed",
1468 &identity.field_version_seeds.occurrence,
1469 )
1470 .set("frequency", "daily")
1471 .set("interval", 1)
1472 .set("weekdays", "")
1473 .set("timezone", "UTC")
1474 .set("start_on", "2026-07-20")
1475 .set("available_local_time", "")
1476 .set("due_policy", "same_day")
1477 .into_value();
1478 let mut change = make_change_wire(
1479 op_type::PROJECT_RECURRENCE_OCCURRENCE,
1480 "recurrence_series",
1481 series_id.as_str(),
1482 payload,
1483 );
1484 change.change_id = identity.occurrence_change_id;
1485 change.field = Some("projection".to_string());
1486 change.created_at = identity.occurrence_link.projected_at;
1487 validate_pushed_change(&change).unwrap();
1488
1489 change.created_at = "2026-07-20T00:00:01Z".to_string();
1490 assert!(
1491 validate_pushed_change(&change)
1492 .unwrap_err()
1493 .to_string()
1494 .contains("recurrence-deterministic-mismatch")
1495 );
1496 }
1497
1498 #[test]
1499 fn request_pull_limit_has_default_and_bounds() {
1500 assert_eq!(request_pull_limit(None).unwrap(), MAX_PULL_BATCH);
1501 assert!(request_pull_limit(Some(MAX_PULL_BATCH)).is_ok());
1502 assert_eq!(
1503 request_pull_limit(Some(0)).unwrap_err().to_string(),
1504 "error sync-pull-limit-out-of-range min=1 max=512 got=0"
1505 );
1506 assert_eq!(
1507 request_pull_limit(Some(MAX_PULL_BATCH + 1))
1508 .unwrap_err()
1509 .to_string(),
1510 "error sync-pull-limit-out-of-range min=1 max=512 got=513"
1511 );
1512 }
1513
1514 #[test]
1515 fn request_envelope_rejects_negative_cursor_and_oversized_push_batch() {
1516 let request = SyncRequest {
1517 protocol_version: Some(SYNC_PROTOCOL_VERSION),
1518 client_id: "test-client".to_string(),
1519 after: -1,
1520 pull_limit: Some(MAX_PULL_BATCH),
1521 changes: Vec::new(),
1522 };
1523 assert_eq!(
1524 validate_sync_request_envelope(&request)
1525 .unwrap_err()
1526 .to_string(),
1527 "error sync-after-out-of-range min=0 got=-1"
1528 );
1529
1530 let request = SyncRequest {
1531 protocol_version: Some(SYNC_PROTOCOL_VERSION),
1532 client_id: "test-client".to_string(),
1533 after: 0,
1534 pull_limit: Some(MAX_PULL_BATCH),
1535 changes: vec![
1536 ChangeWire {
1537 change_id: "AAAAAAAAAAAAAAA0".to_string(),
1538 client_id: "client".to_string(),
1539 local_seq: 1,
1540 entity_type: "task".to_string(),
1541 entity_id: "BBBBBBBBBBBBBBBB".to_string(),
1542 field: None,
1543 op_type: "create_task".to_string(),
1544 payload: serde_json::json!({"title":"oops","project_id":"0000000000000000","project_key":"app","project_name":"app","project_prefix":"APP","workspace_id":"0000000000000000","workspace_key":"default","created_at":"2026-01-01T00:00:00Z"}),
1545 base_version: None,
1546 created_at: "2026-01-01T00:00:00Z".to_string(),
1547 server_seq: None,
1548 };
1549 MAX_PUSH_BATCH + 1
1550 ],
1551 };
1552 assert_eq!(
1553 validate_sync_request_envelope(&request)
1554 .unwrap_err()
1555 .to_string(),
1556 "error sync-push-too-large limit=256 got=257"
1557 );
1558 }
1559
1560 #[test]
1561 fn response_validation_respects_request_pull_limit() {
1562 let response = SyncResponse {
1563 protocol_version: SYNC_PROTOCOL_VERSION,
1564 cursor: 1,
1565 has_more: false,
1566 push_acks: vec![],
1567 changes: vec![
1568 ChangeWire {
1569 change_id: "AAAAAAAAAAAAAAA1".to_string(),
1570 client_id: "client".to_string(),
1571 local_seq: 1,
1572 entity_type: "task".to_string(),
1573 entity_id: "BBBBBBBBBBBBBBBB".to_string(),
1574 field: None,
1575 op_type: "create_task".to_string(),
1576 payload: serde_json::json!({
1577 "title":"one",
1578 "project_id":"0000000000000000",
1579 "project_key":"app",
1580 "project_name":"app",
1581 "project_prefix":"APP",
1582 }),
1583 base_version: None,
1584 created_at: "2026-01-01T00:00:00Z".to_string(),
1585 server_seq: Some(1),
1586 };
1587 MAX_PULL_BATCH as usize + 1
1588 ],
1589 };
1590 assert_eq!(
1591 validate_sync_response_for_request(0, MAX_PULL_BATCH, &[], &response)
1592 .unwrap_err()
1593 .to_string(),
1594 "error invalid-sync-response pull-too-large limit=512 got=513"
1595 );
1596 }
1597
1598 #[test]
1599 fn constructed_create_task_payload_passes_wire_validation() {
1600 let ws = test_workspace();
1601 let payload = ChangePayload::workspace(&ws)
1602 .set("title", "test task")
1603 .set("description", "a description")
1604 .set("project_id", "1111111111111111")
1605 .set("project_key", "app")
1606 .set("project_name", "App")
1607 .set("project_prefix", "APP")
1608 .set("status", "inbox")
1609 .set("priority", "none")
1610 .set("created_at", "2026-06-01T00:00:00Z")
1611 .into_value();
1612 let change = make_change_wire(op_type::CREATE_TASK, "task", "BBBBBBBBBBBBBBBB", payload);
1613 validate_pushed_change(&change)
1614 .expect("create_task payload built with ChangePayload should be wire-valid");
1615 }
1616
1617 #[test]
1618 fn constructed_create_project_payload_passes_wire_validation() {
1619 let ws = test_workspace();
1620 let payload = ChangePayload::workspace(&ws)
1621 .set("key", "app")
1622 .set("name", "App")
1623 .set("prefix", "APP")
1624 .set("created_at", "2026-06-01T00:00:00Z")
1625 .into_value();
1626 let change = make_change_wire(
1627 op_type::CREATE_PROJECT,
1628 "project",
1629 "1111111111111111",
1630 payload,
1631 );
1632 validate_pushed_change(&change)
1633 .expect("create_project payload built with ChangePayload should be wire-valid");
1634 }
1635
1636 #[test]
1637 fn project_changes_reject_invalid_project_ids() {
1638 let ws = test_workspace();
1639 let project_payload = ChangePayload::workspace(&ws)
1640 .set("key", "app")
1641 .set("name", "App")
1642 .set("prefix", "APP")
1643 .set("created_at", "2026-06-01T00:00:00Z")
1644 .into_value();
1645 let create_project = make_change_wire(
1646 op_type::CREATE_PROJECT,
1647 "project",
1648 "invalid",
1649 project_payload,
1650 );
1651 assert_eq!(
1652 validate_pushed_change(&create_project)
1653 .unwrap_err()
1654 .to_string(),
1655 "error invalid-sync-change entity_id invalid-id"
1656 );
1657
1658 let task_payload = ChangePayload::workspace(&ws)
1659 .set("title", "test task")
1660 .set("project_id", "invalid")
1661 .set("project_key", "app")
1662 .set("project_name", "App")
1663 .set("project_prefix", "APP")
1664 .set("created_at", "2026-06-01T00:00:00Z")
1665 .into_value();
1666 let create_task = make_change_wire(
1667 op_type::CREATE_TASK,
1668 "task",
1669 "BBBBBBBBBBBBBBBB",
1670 task_payload,
1671 );
1672 assert_eq!(
1673 validate_pushed_change(&create_task)
1674 .unwrap_err()
1675 .to_string(),
1676 "error invalid-sync-change project_id invalid-id"
1677 );
1678
1679 let field_payload = ChangePayload::workspace(&ws)
1680 .set("value", "invalid")
1681 .set("project_id", "invalid")
1682 .set("project_key", "app")
1683 .set("project_name", "App")
1684 .set("project_prefix", "APP")
1685 .into_value();
1686 let mut set_field = make_change_wire(
1687 op_type::SET_FIELD,
1688 "task",
1689 "BBBBBBBBBBBBBBBB",
1690 field_payload,
1691 );
1692 set_field.field = Some("project".to_string());
1693 assert_eq!(
1694 validate_pushed_change(&set_field).unwrap_err().to_string(),
1695 "error invalid-sync-change project_id invalid-id"
1696 );
1697 }
1698
1699 #[test]
1700 fn constructed_label_add_payload_passes_wire_validation() {
1701 let ws = test_workspace();
1702 let payload = ChangePayload::workspace(&ws)
1703 .set("label", "bug")
1704 .into_value();
1705 let mut change = make_change_wire(op_type::LABEL_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1706 change.field = Some("labels".to_string());
1707 validate_pushed_change(&change)
1708 .expect("label_add payload built with ChangePayload should be wire-valid");
1709 }
1710
1711 #[test]
1712 fn constructed_label_administration_payloads_pass_wire_validation() {
1713 let ws = test_workspace();
1714 let rename = make_change_wire(
1715 op_type::SET_LABEL_NAME,
1716 "label",
1717 "old",
1718 ChangePayload::workspace(&ws)
1719 .set("name", "old")
1720 .set("new_name", "new")
1721 .set("renamed_at", "2026-06-01T00:00:00Z")
1722 .into_value(),
1723 );
1724 validate_pushed_change(&rename).unwrap();
1725
1726 let restore = make_change_wire(
1727 op_type::LABEL_RESTORE,
1728 "label",
1729 "new",
1730 ChangePayload::workspace(&ws)
1731 .set("name", "new")
1732 .set("created_at", "2026-06-01T00:00:00Z")
1733 .set("task_ids", ["BBBBBBBBBBBBBBBB"])
1734 .set("series_ids", ["CCCCCCCCCCCCCCCC"])
1735 .set("restored_at", "2026-06-01T00:00:01Z")
1736 .into_value(),
1737 );
1738 validate_pushed_change(&restore).unwrap();
1739 }
1740
1741 #[test]
1742 fn constructed_dependency_add_payload_passes_wire_validation() {
1743 let ws = test_workspace();
1744 let payload = ChangePayload::workspace(&ws)
1745 .set("depends_on_task_id", "CCCCCCCCCCCCCCCC")
1746 .into_value();
1747 let mut change =
1748 make_change_wire(op_type::DEPENDENCY_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1749 change.field = Some("dependencies".to_string());
1750 validate_pushed_change(&change)
1751 .expect("dependency_add payload built with ChangePayload should be wire-valid");
1752 }
1753
1754 #[test]
1755 fn constructed_note_edit_payload_passes_wire_validation() {
1756 let ws = test_workspace();
1757 let payload = ChangePayload::workspace(&ws)
1758 .set("note_id", "DDDDDDDDDDDDDDDD")
1759 .set("body", "corrected note body")
1760 .set("edited_at", "2026-06-01T01:00:00Z")
1761 .into_value();
1762 let mut change = make_change_wire(op_type::NOTE_EDIT, "task", "BBBBBBBBBBBBBBBB", payload);
1763 change.field = Some("notes".to_string());
1764 validate_pushed_change(&change)
1765 .expect("note_edit payload built with ChangePayload should be wire-valid");
1766 }
1767
1768 #[test]
1769 fn constructed_note_add_payload_passes_wire_validation() {
1770 let ws = test_workspace();
1771 let payload = ChangePayload::workspace(&ws)
1772 .set("note_id", "DDDDDDDDDDDDDDDD")
1773 .set("body", "note body")
1774 .set("created_at", "2026-06-01T00:00:00Z")
1775 .into_value();
1776 let mut change = make_change_wire(op_type::NOTE_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1777 change.field = Some("notes".to_string());
1778 validate_pushed_change(&change)
1779 .expect("note_add payload built with ChangePayload should be wire-valid");
1780 }
1781}