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, 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 = 12;
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_TASK => {
367 ensure_entity_type(change, "task")?;
368 ensure_sync_id("entity_id", &change.entity_id)?;
369 optional_workspace_payload(&change.payload)?;
370 required_string_payload("title", &change.payload)?;
371 let project_id = required_string_payload("project_id", &change.payload)?;
372 ensure_project_id("project_id", &project_id)?;
373 optional_string_payload("description", &change.payload)?;
374 let recurrence_task = change
375 .payload
376 .get("series_id")
377 .and_then(Value::as_str)
378 .is_some();
379 if recurrence_task {
380 let series_id = required_string_payload("series_id", &change.payload)?;
381 series_id.parse::<RecurrenceSeriesId>().map_err(|_| {
382 anyhow::anyhow!("error invalid-sync-change series_id invalid-id")
383 })?;
384 validate_recurrence_task(change)?;
385 } else {
386 required_string_payload("project_key", &change.payload)?;
387 required_string_payload("project_name", &change.payload)?;
388 required_string_payload("project_prefix", &change.payload)?;
389 }
390 if let Some(status) = optional_string_payload("status", &change.payload)? {
391 validate_sync_task_field_value(TaskField::Status, &status)?;
392 }
393 if let Some(priority) = optional_string_payload("priority", &change.payload)? {
394 validate_sync_task_field_value(TaskField::Priority, &priority)?;
395 }
396 if let Some(source) = optional_string_payload("source", &change.payload)? {
397 TaskSource::parse(&source)
398 .context("error invalid-sync-change invalid-task-source")?;
399 }
400 if let Some(available_at) = optional_string_payload("available_at", &change.payload)? {
401 validate_sync_task_field_value(TaskField::AvailableAt, &available_at)?;
402 }
403 if let Some(due_on) = optional_string_payload("due_on", &change.payload)? {
404 validate_sync_task_field_value(TaskField::DueOn, &due_on)?;
405 }
406 if let Some(is_epic) = optional_string_payload("is_epic", &change.payload)? {
407 validate_sync_task_field_value(TaskField::IsEpic, &is_epic)?;
408 }
409 optional_string_array_payload("labels", &change.payload)?;
410 optional_string_payload("created_at", &change.payload)?;
411 }
412 op_type::SET_FIELD | op_type::RESOLVE_FIELD => {
413 ensure_entity_type(change, "task")?;
414 ensure_sync_id("entity_id", &change.entity_id)?;
415 optional_workspace_payload(&change.payload)?;
416 let field = change
417 .field
418 .as_deref()
419 .filter(|field| !field.trim().is_empty())
420 .context("error invalid-sync-change field missing")?;
421 let task_field = TaskField::parse_for_sync(field)?;
422 let value = required_string_payload("value", &change.payload)?;
423 validate_sync_task_field_value(task_field, &value)?;
424 if task_field == TaskField::Project {
425 let project_id = required_string_payload("project_id", &change.payload)?;
426 ensure_project_id("project_id", &project_id)?;
427 if value != project_id {
428 bail!("error invalid-sync-change project-value-mismatch");
429 }
430 required_string_payload("project_key", &change.payload)?;
431 required_string_payload("project_name", &change.payload)?;
432 required_string_payload("project_prefix", &change.payload)?;
433 }
434 }
435 op_type::LABEL_ADD | op_type::LABEL_REMOVE => {
436 ensure_entity_type(change, "task")?;
437 ensure_sync_id("entity_id", &change.entity_id)?;
438 optional_workspace_payload(&change.payload)?;
439 required_string_payload("label", &change.payload)?;
440 }
441 op_type::NOTE_ADD => {
442 ensure_entity_type(change, "task")?;
443 ensure_sync_id("entity_id", &change.entity_id)?;
444 optional_workspace_payload(&change.payload)?;
445 let note_id = required_string_payload("note_id", &change.payload)?;
446 ensure_sync_id("note_id", ¬e_id)?;
447 required_string_payload("body", &change.payload)?;
448 required_string_payload("created_at", &change.payload)?;
449 }
450 op_type::DEPENDENCY_ADD | op_type::DEPENDENCY_REMOVE => {
451 ensure_entity_type(change, "task")?;
452 ensure_sync_id("entity_id", &change.entity_id)?;
453 required_workspace_payload(&change.payload)?;
454 let depends_on_task_id =
455 required_string_payload("depends_on_task_id", &change.payload)?;
456 ensure_sync_id("depends_on_task_id", &depends_on_task_id)?;
457 if change.entity_id == depends_on_task_id {
458 bail!("error invalid-sync-change dependency-self");
459 }
460 }
461 op_type::EPIC_LINK_ADD | op_type::EPIC_LINK_REMOVE => {
462 ensure_entity_type(change, "task")?;
463 ensure_sync_id("entity_id", &change.entity_id)?;
464 required_workspace_payload(&change.payload)?;
465 let epic_task_id = required_string_payload("epic_task_id", &change.payload)?;
466 ensure_sync_id("epic_task_id", &epic_task_id)?;
467 if change.entity_id == epic_task_id {
468 bail!("error invalid-sync-change epic-self");
469 }
470 if change.op_type == op_type::EPIC_LINK_ADD {
471 required_timestamp_payload("created_at", &change.payload)?;
472 }
473 }
474 op_type::PROJECT_DELETE => {
475 ensure_entity_type(change, "project")?;
476 ensure_project_id("entity_id", &change.entity_id)?;
477 required_workspace_payload(&change.payload)?;
478 required_timestamp_payload("deleted_at", &change.payload)?;
479 }
480 op_type::SET_LABEL_NAME => {
481 ensure_entity_type(change, "label")?;
482 required_workspace_payload(&change.payload)?;
483 let name = required_string_payload("name", &change.payload)?;
484 if name != change.entity_id {
485 bail!("error invalid-sync-change label-value-mismatch");
486 }
487 required_string_payload("new_name", &change.payload)?;
488 required_timestamp_payload("renamed_at", &change.payload)?;
489 }
490 op_type::LABEL_DELETE => {
491 ensure_entity_type(change, "label")?;
492 required_workspace_payload(&change.payload)?;
493 let name = required_string_payload("name", &change.payload)?;
494 if name != change.entity_id {
495 bail!("error invalid-sync-change label-value-mismatch");
496 }
497 required_timestamp_payload("deleted_at", &change.payload)?;
498 }
499 op_type::NOTE_EDIT => {
500 ensure_entity_type(change, "task")?;
501 ensure_sync_id("entity_id", &change.entity_id)?;
502 if change.field.as_deref() != Some("notes") {
503 bail!("error invalid-sync-change field=notes");
504 }
505 required_workspace_payload(&change.payload)?;
506 let note_id = required_string_payload("note_id", &change.payload)?;
507 ensure_sync_id("note_id", ¬e_id)?;
508 required_string_payload("body", &change.payload)?;
509 required_timestamp_payload("edited_at", &change.payload)?;
510 }
511 op_type::LABEL_RESTORE => {
512 ensure_entity_type(change, "label")?;
513 required_workspace_payload(&change.payload)?;
514 let name = required_string_payload("name", &change.payload)?;
515 if name != change.entity_id {
516 bail!("error invalid-sync-change label-value-mismatch");
517 }
518 required_timestamp_payload("created_at", &change.payload)?;
519 required_timestamp_payload("restored_at", &change.payload)?;
520 for key in ["task_ids", "series_ids"] {
521 optional_string_array_payload(key, &change.payload)?;
522 if change.payload.get(key).is_none() {
523 bail!("error invalid-sync-change payload.{key} missing");
524 }
525 }
526 for task_id in string_array_payload("task_ids", &change.payload)? {
527 ensure_sync_id("task_ids", &task_id)?;
528 }
529 for series_id in string_array_payload("series_ids", &change.payload)? {
530 ensure_sync_id("series_ids", &series_id)?;
531 }
532 }
533 op_type::NOTE_DELETE => {
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_timestamp_payload("deleted_at", &change.payload)?;
543 }
544 op_type::CREATE_RECURRENCE_SERIES => validate_recurrence_create(change)?,
545 op_type::UPDATE_RECURRENCE_TEMPLATE => validate_recurrence_template(change)?,
546 op_type::PROJECT_RECURRENCE_OCCURRENCE => validate_recurrence_projection(change)?,
547 op_type::RESOLVE_RECURRENCE_OCCURRENCE => validate_recurrence_outcome(change)?,
548 op_type::SET_RECURRENCE_STATE | op_type::STOP_RECURRENCE_SERIES => {
549 validate_recurrence_state(change)?
550 }
551 op_type::OPEN_RECURRENCE_PAUSE => validate_recurrence_pause(change, true)?,
552 op_type::CLOSE_RECURRENCE_PAUSE => validate_recurrence_pause(change, false)?,
553 op_type::ATTACHMENT_ADD => validate_attachment_add_change(change)?,
554 op_type::ATTACHMENT_DELETE => validate_attachment_delete_change(change)?,
555 _ => bail!("error invalid-sync-change op_type={}", change.op_type),
556 }
557 Ok(())
558}
559
560fn validate_recurrence_task(change: &ChangeWire) -> Result<()> {
561 let workspace_id: WorkspaceId = required_string_payload("workspace_id", &change.payload)?
562 .parse()
563 .map_err(|_| anyhow::anyhow!("error invalid-sync-change workspace_id invalid-id"))?;
564 let series_id: RecurrenceSeriesId = required_string_payload("series_id", &change.payload)?
565 .parse()
566 .map_err(|_| anyhow::anyhow!("error invalid-sync-change series_id invalid-id"))?;
567 let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
568 let schedule = recurrence_schedule_payload(&change.payload)?;
569 if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
570 bail!("error invalid-sync-change recurrence-slot-off-lattice");
571 }
572 let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)
573 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
574 let slot = crate::recurrence::slot_values(&schedule, slot_on)
575 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
576 for (key, expected) in [
577 ("task_id", identity.task_id.as_str()),
578 ("created_at", identity.created_at.as_str()),
579 ("updated_at", identity.updated_at.as_str()),
580 ("available_at", slot.available_at.as_str()),
581 ("due_on", slot.due_on.as_deref().unwrap_or("")),
582 ("task_change_id", identity.task_change_id.as_str()),
583 (
584 "occurrence_change_id",
585 identity.occurrence_change_id.as_str(),
586 ),
587 (
588 "task_field_version_seed",
589 identity.field_version_seeds.task.as_str(),
590 ),
591 (
592 "occurrence_field_version_seed",
593 identity.field_version_seeds.occurrence.as_str(),
594 ),
595 ] {
596 if required_string_payload(key, &change.payload)? != expected {
597 bail!("error invalid-sync-change recurrence-deterministic-mismatch field={key}");
598 }
599 }
600 if change.entity_id != identity.task_id.as_str()
601 || change.change_id != identity.task_change_id
602 || change.created_at != identity.created_at
603 {
604 bail!("error invalid-sync-change recurrence-deterministic-mismatch field=change_identity");
605 }
606 Ok(())
607}
608
609fn validate_recurrence_create(change: &ChangeWire) -> Result<()> {
610 ensure_entity_type(change, "recurrence_series")?;
611 let series_id: RecurrenceSeriesId = change
612 .entity_id
613 .parse()
614 .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
615 required_workspace_payload(&change.payload)?;
616 if required_string_payload("series_id", &change.payload)? != series_id.as_str() {
617 bail!("error invalid-sync-change recurrence-series-id-mismatch");
618 }
619 required_string_payload("title", &change.payload)?;
620 required_string_payload("description", &change.payload)?;
621 let project_id = required_string_payload("project_id", &change.payload)?;
622 ensure_project_id("project_id", &project_id)?;
623 required_string_payload("project_key", &change.payload)?;
624 required_string_payload("project_name", &change.payload)?;
625 required_string_payload("project_prefix", &change.payload)?;
626 validate_sync_task_field_value(
627 TaskField::Priority,
628 &required_string_payload("priority", &change.payload)?,
629 )?;
630 let status = required_string_payload("initial_status", &change.payload)?;
631 validate_sync_task_field_value(TaskField::Status, &status)?;
632 if matches!(status.as_str(), "done" | "canceled") {
633 bail!("error invalid-sync-change recurrence-terminal-template");
634 }
635 recurrence_schedule_payload(&change.payload)?;
636 optional_string_array_payload("labels", &change.payload)?;
637 if required_string_payload("state", &change.payload)? != "active"
638 || !required_string_payload("stopped_at", &change.payload)?.is_empty()
639 {
640 bail!("error invalid-sync-change recurrence-create-state");
641 }
642 required_timestamp_payload("created_at", &change.payload)?;
643 required_timestamp_payload("updated_at", &change.payload)?;
644 Ok(())
645}
646
647fn validate_recurrence_template(change: &ChangeWire) -> Result<()> {
648 validate_recurrence_entity(change)?;
649 let fields = change
650 .payload
651 .get("fields")
652 .and_then(Value::as_array)
653 .context("error invalid-sync-change payload.fields missing")?;
654 if fields.len() > 8 {
655 bail!("error invalid-sync-change recurrence-template-fields-too-large");
656 }
657 let base_versions = change
658 .payload
659 .get("base_versions")
660 .and_then(Value::as_object)
661 .context("error invalid-sync-change payload.base_versions missing")?;
662 let mut seen = HashSet::new();
663 for pair in fields {
664 let pair = pair
665 .as_array()
666 .filter(|pair| pair.len() == 2)
667 .context("error invalid-sync-change recurrence-template-field-pair")?;
668 let field = pair[0]
669 .as_str()
670 .context("error invalid-sync-change recurrence-template-field")?;
671 let value = pair[1]
672 .as_str()
673 .context("error invalid-sync-change recurrence-template-value")?;
674 if !seen.insert(field) {
675 bail!("error invalid-sync-change recurrence-template-duplicate-field");
676 }
677 if !matches!(
678 base_versions.get(field),
679 Some(Value::String(_)) | Some(Value::Null)
680 ) {
681 bail!("error invalid-sync-change recurrence-template-base-version field={field}");
682 }
683 match field {
684 "title" | "description" => {}
685 "project" => ensure_project_id("project_id", value)?,
686 "priority" => validate_sync_task_field_value(TaskField::Priority, value)?,
687 "initial_status" => {
688 validate_sync_task_field_value(TaskField::Status, value)?;
689 if matches!(value, "done" | "canceled") {
690 bail!("error invalid-sync-change recurrence-terminal-template");
691 }
692 }
693 "available_local_time" => {
694 validate_local_time(value)?;
695 }
696 "due_policy" => {
697 RecurrenceDuePolicy::parse(value)
698 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
699 }
700 _ => bail!("error invalid-sync-change recurrence-template-field={field}"),
701 }
702 }
703 optional_string_array_payload("labels", &change.payload)?;
704 let labels_changed = change
705 .payload
706 .get("labels_changed")
707 .and_then(Value::as_bool)
708 .context("error invalid-sync-change payload.labels_changed missing")?;
709 if labels_changed
710 && !matches!(
711 base_versions.get("labels"),
712 Some(Value::String(_)) | Some(Value::Null)
713 )
714 {
715 bail!("error invalid-sync-change recurrence-template-base-version field=labels");
716 }
717 required_timestamp_payload("updated_at", &change.payload)?;
718 Ok(())
719}
720
721fn validate_recurrence_projection(change: &ChangeWire) -> Result<()> {
722 validate_recurrence_entity(change)?;
723 if change.field.as_deref() != Some("projection") {
724 bail!("error invalid-sync-change field=projection");
725 }
726 let workspace_id: WorkspaceId = required_string_payload("workspace_id", &change.payload)?
727 .parse()
728 .map_err(|_| anyhow::anyhow!("error invalid-sync-change workspace_id invalid-id"))?;
729 let series_id: RecurrenceSeriesId = change.entity_id.parse().unwrap();
730 let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
731 let schedule = recurrence_schedule_payload(&change.payload)?;
732 if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
733 bail!("error invalid-sync-change recurrence-slot-off-lattice");
734 }
735 let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)
736 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
737 for (key, expected) in [
738 ("task_id", identity.task_id.as_str()),
739 (
740 "projected_at",
741 identity.occurrence_link.projected_at.as_str(),
742 ),
743 ("task_change_id", identity.task_change_id.as_str()),
744 (
745 "occurrence_change_id",
746 identity.occurrence_change_id.as_str(),
747 ),
748 (
749 "task_field_version_seed",
750 identity.field_version_seeds.task.as_str(),
751 ),
752 (
753 "occurrence_field_version_seed",
754 identity.field_version_seeds.occurrence.as_str(),
755 ),
756 ] {
757 if required_string_payload(key, &change.payload)? != expected {
758 bail!("error invalid-sync-change recurrence-deterministic-mismatch field={key}");
759 }
760 }
761 if change.change_id != identity.occurrence_change_id
762 || change.created_at != identity.occurrence_link.projected_at
763 {
764 bail!("error invalid-sync-change recurrence-deterministic-mismatch field=change_id");
765 }
766 Ok(())
767}
768
769fn validate_recurrence_outcome(change: &ChangeWire) -> Result<()> {
770 validate_recurrence_entity(change)?;
771 validate_timestamp_value("created_at", &change.created_at)?;
772 if change.field.as_deref() != Some("outcome") {
773 bail!("error invalid-sync-change field=outcome");
774 }
775 let slot_on = recurrence_date_payload("slot_on", &change.payload)?;
776 let schedule = recurrence_schedule_payload(&change.payload)?;
777 if !is_slot(&schedule.rule, schedule.start_on, slot_on) {
778 bail!("error invalid-sync-change recurrence-slot-off-lattice");
779 }
780 let outcome = RecurrenceOutcome::parse(&required_string_payload("outcome", &change.payload)?)
781 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
782 let resolved_at = required_timestamp_payload("resolved_at", &change.payload)?;
783 if resolved_at < crate::recurrence::slot_values(&schedule, slot_on)?.boundary_at {
784 bail!("error invalid-sync-change recurrence-resolution-before-slot");
785 }
786 let conflict_resolution = change
787 .payload
788 .get("conflict_resolution")
789 .and_then(Value::as_bool)
790 .unwrap_or(false);
791 let task_id = required_string_payload("task_id", &change.payload)?;
792 ensure_sync_id("task_id", &task_id)?;
793 let workspace_id: WorkspaceId =
794 required_string_payload("workspace_id", &change.payload)?.parse()?;
795 let series_id: RecurrenceSeriesId = change.entity_id.parse().unwrap();
796 let identity = derive_occurrence_identity(&workspace_id, &series_id, &schedule, slot_on)?;
797 if task_id != identity.task_id.as_str() {
798 bail!("error invalid-sync-change recurrence-deterministic-mismatch field=task_id");
799 }
800 let expected_status = match outcome {
801 RecurrenceOutcome::Completed => "done",
802 RecurrenceOutcome::Skipped => "canceled",
803 };
804 if required_string_payload("task_status", &change.payload)? != expected_status {
805 bail!("error invalid-sync-change recurrence-outcome-status-mismatch");
806 }
807 let status_change_id = required_string_payload("task_status_change_id", &change.payload)?;
808 if !conflict_resolution || !status_change_id.is_empty() {
809 ensure_sync_id("task_status_change_id", &status_change_id)?;
810 }
811 let successor = required_string_payload("successor_task_id", &change.payload)?;
812 if !successor.is_empty() {
813 ensure_sync_id("successor_task_id", &successor)?;
814 let successor_slot = next_slot_after(&schedule.rule, schedule.start_on, slot_on)
815 .context("error invalid-sync-change recurrence-successor-out-of-range")?;
816 let successor_identity =
817 derive_occurrence_identity(&workspace_id, &series_id, &schedule, successor_slot)?;
818 if successor != successor_identity.task_id.as_str() {
819 bail!(
820 "error invalid-sync-change recurrence-deterministic-mismatch field=successor_task_id"
821 );
822 }
823 }
824 Ok(())
825}
826
827fn validate_recurrence_state(change: &ChangeWire) -> Result<()> {
828 validate_recurrence_entity(change)?;
829 if change.field.as_deref() != Some("state") {
830 bail!("error invalid-sync-change field=state");
831 }
832 let conflict_resolution = change
833 .payload
834 .get("conflict_resolution")
835 .and_then(Value::as_bool)
836 .unwrap_or(false);
837 if change.base_version.is_none() && !conflict_resolution {
838 bail!("error invalid-sync-change recurrence-lifecycle-base-version-missing");
839 }
840 let state = RecurrenceSeriesState::parse(&required_string_payload("state", &change.payload)?)
841 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
842 let stopped_at = required_string_payload("stopped_at", &change.payload)?;
843 if matches!(state, RecurrenceSeriesState::Stopped) {
844 required_timestamp_payload("stopped_at", &change.payload)?;
845 } else if !stopped_at.is_empty() {
846 bail!("error invalid-sync-change recurrence-stop-state-mismatch");
847 }
848 required_timestamp_payload("changed_at", &change.payload)?;
849 Ok(())
850}
851
852fn validate_recurrence_pause(change: &ChangeWire, open: bool) -> Result<()> {
853 validate_recurrence_entity(change)?;
854 if change.field.as_deref() != Some("pause") {
855 bail!("error invalid-sync-change field=pause");
856 }
857 if open {
858 let interval_id = required_string_payload("interval_id", &change.payload)?;
859 ensure_sync_id("interval_id", &interval_id)?;
860 required_timestamp_payload("paused_at", &change.payload)?;
861 let slot = required_string_payload("suspended_slot_on", &change.payload)?;
862 if !slot.is_empty() {
863 slot.parse::<NaiveDate>()
864 .context("error invalid-sync-change suspended_slot_on")?;
865 let task_id = required_string_payload("suspended_task_id", &change.payload)?;
866 ensure_sync_id("suspended_task_id", &task_id)?;
867 }
868 } else {
869 required_timestamp_payload("resumed_at", &change.payload)?;
870 if let Some(interval_id) = optional_string_payload("interval_id", &change.payload)? {
871 ensure_sync_id("interval_id", &interval_id)?;
872 }
873 }
874 Ok(())
875}
876
877fn validate_recurrence_entity(change: &ChangeWire) -> Result<()> {
878 ensure_entity_type(change, "recurrence_series")?;
879 change
880 .entity_id
881 .parse::<RecurrenceSeriesId>()
882 .map_err(|_| anyhow::anyhow!("error invalid-sync-change entity_id invalid-id"))?;
883 required_workspace_payload(&change.payload)?;
884 Ok(())
885}
886
887fn recurrence_schedule_payload(payload: &Value) -> Result<RecurrenceSchedule> {
888 let frequency = RecurrenceFrequency::parse(&required_string_payload("frequency", payload)?)
889 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
890 let interval = required_i64_payload("interval", payload)?;
891 let interval = u32::try_from(interval)
892 .context("error invalid-sync-change recurrence-interval-out-of-range")?;
893 let weekdays_text = required_string_payload("weekdays", payload)?;
894 let weekdays = weekdays_text
895 .parse::<WeekdaySet>()
896 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
897 if weekdays.to_string() != weekdays_text {
898 bail!("error invalid-sync-change recurrence-weekdays-noncanonical");
899 }
900 let rule = RecurrenceRule::new(frequency, interval, weekdays)
901 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
902 let timezone_text = required_string_payload("timezone", payload)?;
903 let timezone = timezone_text
904 .parse::<TimeZoneId>()
905 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
906 if timezone_text.parse::<chrono_tz::Tz>()?.to_string() != timezone_text {
907 bail!("error invalid-sync-change recurrence-timezone-noncanonical");
908 }
909 let start_on = recurrence_date_payload("start_on", payload)?;
910 let available = required_string_payload("available_local_time", payload)?;
911 let available_local_time = if available.is_empty() {
912 None
913 } else {
914 Some(validate_local_time(&available)?)
915 };
916 let due_policy = RecurrenceDuePolicy::parse(&required_string_payload("due_policy", payload)?)
917 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))?;
918 Ok(RecurrenceSchedule::new(
919 rule,
920 timezone,
921 start_on,
922 available_local_time,
923 due_policy,
924 ))
925}
926
927fn recurrence_date_payload(key: &str, payload: &Value) -> Result<NaiveDate> {
928 required_string_payload(key, payload)?
929 .parse()
930 .with_context(|| format!("error invalid-sync-change payload.{key} invalid-date"))
931}
932
933fn validate_local_time(value: &str) -> Result<NaiveTime> {
934 NaiveTime::parse_from_str(value, "%H:%M:%S")
935 .context("error invalid-sync-change recurrence-local-time")
936}
937
938fn validate_attachment_add_change(change: &ChangeWire) -> Result<()> {
939 ensure_entity_type(change, "task")?;
940 ensure_sync_id("entity_id", &change.entity_id)?;
941 ensure_attachment_field(change)?;
942 required_workspace_payload(&change.payload)?;
943 let attachment_id = required_string_payload("attachment_id", &change.payload)?;
944 ensure_sync_id("attachment_id", &attachment_id)?;
945 let sha256 = required_string_payload("sha256", &change.payload)?;
946 validate_sha256_for_sync(&sha256)?;
947 let byte_size = required_i64_payload("byte_size", &change.payload)?;
948 validate_blob_size_for_sync(byte_size)?;
949 let media_type = required_string_payload("media_type", &change.payload)?;
950 map_attachment_validation(validate_media_type(&media_type))?;
951 let filename = optional_string_payload("filename", &change.payload)?;
952 map_attachment_validation(validate_filename(filename.as_deref()))?;
953 let alt_text = optional_string_payload("alt_text", &change.payload)?;
954 map_attachment_validation(validate_alt_text(alt_text.as_deref()))?;
955 let width = optional_i64_payload("width", &change.payload)?;
956 let height = optional_i64_payload("height", &change.payload)?;
957 map_attachment_validation(validate_dimensions(width, height))?;
958 required_timestamp_payload("created_at", &change.payload)?;
959 Ok(())
960}
961
962fn validate_attachment_delete_change(change: &ChangeWire) -> Result<()> {
963 ensure_entity_type(change, "task")?;
964 ensure_sync_id("entity_id", &change.entity_id)?;
965 ensure_attachment_field(change)?;
966 required_workspace_payload(&change.payload)?;
967 let attachment_id = required_string_payload("attachment_id", &change.payload)?;
968 ensure_sync_id("attachment_id", &attachment_id)?;
969 required_timestamp_payload("deleted_at", &change.payload)?;
970 Ok(())
971}
972
973fn ensure_attachment_field(change: &ChangeWire) -> Result<()> {
974 if change.field.as_deref() != Some("attachments") {
975 bail!("error invalid-sync-change field=attachments");
976 }
977 Ok(())
978}
979
980fn validate_sha256_for_sync(value: &str) -> Result<()> {
981 if value.len() == 64
982 && value
983 .bytes()
984 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
985 {
986 Ok(())
987 } else {
988 bail!("error invalid-sync-change invalid-sha256");
989 }
990}
991
992fn validate_blob_size_for_sync(byte_size: i64) -> Result<()> {
993 let Ok(bytes) = usize::try_from(byte_size) else {
994 bail!("error invalid-sync-change error invalid-attachment-size bytes={byte_size}");
995 };
996 if bytes == 0 || bytes > MAX_BLOB_BYTES {
997 bail!("error invalid-sync-change error invalid-attachment-size bytes={byte_size}");
998 }
999 map_attachment_validation(validate_blob_size(bytes))
1000}
1001
1002fn map_attachment_validation(result: Result<()>) -> Result<()> {
1003 result.map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))
1004}
1005
1006fn validate_change_server_seq(change: &ChangeWire, direction: ChangeDirection) -> Result<()> {
1007 match direction {
1008 ChangeDirection::Pushed if change.server_seq.is_some() => {
1009 bail!("error invalid-sync-change server_seq client-supplied");
1010 }
1011 ChangeDirection::Pulled => match change.server_seq {
1012 Some(server_seq) if server_seq > 0 => {}
1013 Some(server_seq) => {
1014 bail!("error invalid-sync-change server_seq={server_seq}");
1015 }
1016 None => bail!("error invalid-sync-change server_seq missing"),
1017 },
1018 ChangeDirection::Pushed => {}
1019 }
1020 Ok(())
1021}
1022
1023fn validate_push_acks(request_change_ids: &[String], response: &SyncResponse) -> Result<()> {
1024 if response.push_acks.len() != request_change_ids.len() {
1025 bail!(
1026 "error invalid-sync-response push-ack-count expected={} got={}",
1027 request_change_ids.len(),
1028 response.push_acks.len()
1029 );
1030 }
1031 let expected = request_change_ids
1032 .iter()
1033 .map(String::as_str)
1034 .collect::<HashSet<_>>();
1035 let mut seen = HashSet::with_capacity(response.push_acks.len());
1036 for ack in &response.push_acks {
1037 if !expected.contains(ack.change_id.as_str()) {
1038 bail!(
1039 "error invalid-sync-response unexpected-push-ack change_id={}",
1040 ack.change_id
1041 );
1042 }
1043 if ack.server_seq <= 0 {
1044 bail!(
1045 "error invalid-sync-response push-ack-server-seq change_id={} server_seq={}",
1046 ack.change_id,
1047 ack.server_seq
1048 );
1049 }
1050 if !seen.insert(ack.change_id.as_str()) {
1051 bail!(
1052 "error invalid-sync-response duplicate-push-ack change_id={}",
1053 ack.change_id
1054 );
1055 }
1056 }
1057 Ok(())
1058}
1059
1060fn validate_pull_page(after: i64, pull_limit: u32, response: &SyncResponse) -> Result<()> {
1061 let mut previous = after;
1062 let mut change_ids = HashSet::with_capacity(response.changes.len());
1063 for change in &response.changes {
1064 validate_pulled_change(change)?;
1065 if !change_ids.insert(&change.change_id) {
1066 bail!(
1067 "error invalid-sync-response duplicate-pull-change change_id={}",
1068 change.change_id
1069 );
1070 }
1071 let server_seq = change.server_seq.with_context(|| {
1072 format!(
1073 "error invalid-sync-response missing-server-seq change_id={}",
1074 change.change_id
1075 )
1076 })?;
1077 if server_seq <= previous {
1078 bail!(
1079 "error invalid-sync-response server-seq-order previous={} server_seq={}",
1080 previous,
1081 server_seq
1082 );
1083 }
1084 previous = server_seq;
1085 }
1086 let expected_cursor = response
1087 .changes
1088 .last()
1089 .and_then(|change| change.server_seq)
1090 .unwrap_or(after);
1091 if response.cursor != expected_cursor {
1092 bail!(
1093 "error invalid-sync-response cursor-mismatch expected={} got={}",
1094 expected_cursor,
1095 response.cursor
1096 );
1097 }
1098 if response.has_more && response.changes.len() < pull_limit as usize {
1099 bail!(
1100 "error invalid-sync-response has-more-short-page returned={} limit={}",
1101 response.changes.len(),
1102 pull_limit
1103 );
1104 }
1105 Ok(())
1106}
1107
1108fn validate_push_pull_overlap(response: &SyncResponse) -> Result<()> {
1109 let acked = response
1110 .push_acks
1111 .iter()
1112 .map(|ack| (ack.change_id.as_str(), ack.server_seq))
1113 .collect::<HashMap<_, _>>();
1114 for change in &response.changes {
1115 if let Some(acked_server_seq) = acked.get(change.change_id.as_str()) {
1116 let Some(pull_server_seq) = change.server_seq else {
1117 continue;
1118 };
1119 if *acked_server_seq != pull_server_seq {
1120 bail!(
1121 "error invalid-sync-response push-pull-server-seq-mismatch change_id={} ack={} pull={}",
1122 change.change_id,
1123 acked_server_seq,
1124 pull_server_seq
1125 );
1126 }
1127 }
1128 }
1129 Ok(())
1130}
1131
1132fn validate_sync_task_field_value(field: TaskField, value: &str) -> Result<()> {
1133 field
1134 .validate_value(value)
1135 .map_err(|err| anyhow::anyhow!("error invalid-sync-change {err}"))
1136}
1137
1138fn ensure_entity_type(change: &ChangeWire, expected: &str) -> Result<()> {
1139 if change.entity_type == expected {
1140 Ok(())
1141 } else {
1142 bail!(
1143 "error invalid-sync-change op_type={} entity_type={} expected={}",
1144 change.op_type,
1145 change.entity_type,
1146 expected
1147 )
1148 }
1149}
1150
1151fn ensure_non_empty(name: &str, value: &str) -> Result<()> {
1152 if value.trim().is_empty() {
1153 bail!("error invalid-sync-change {name} empty");
1154 }
1155 Ok(())
1156}
1157
1158fn ensure_sync_id(name: &str, value: &str) -> Result<()> {
1159 if value.len() == 16 && value.bytes().all(|byte| BASE32.contains(&byte)) {
1160 Ok(())
1161 } else {
1162 bail!("error invalid-sync-change {name} invalid-id");
1163 }
1164}
1165
1166fn ensure_project_id(name: &str, value: &str) -> Result<()> {
1167 if value.parse::<ProjectId>().is_ok() {
1168 Ok(())
1169 } else {
1170 bail!("error invalid-sync-change {name} invalid-id");
1171 }
1172}
1173
1174fn required_string_payload(key: &str, payload: &Value) -> Result<String> {
1175 payload
1176 .get(key)
1177 .and_then(Value::as_str)
1178 .map(str::to_string)
1179 .with_context(|| format!("error invalid-sync-change payload.{key} missing"))
1180}
1181
1182fn required_i64_payload(key: &str, payload: &Value) -> Result<i64> {
1183 match payload.get(key) {
1184 Some(Value::Number(value)) => value
1185 .as_i64()
1186 .with_context(|| format!("error invalid-sync-change payload.{key} invalid")),
1187 Some(Value::Null) | None => bail!("error invalid-sync-change payload.{key} missing"),
1188 Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1189 }
1190}
1191
1192fn required_timestamp_payload(key: &str, payload: &Value) -> Result<String> {
1193 let value = required_string_payload(key, payload)?;
1194 validate_timestamp_value(&format!("payload.{key}"), &value)?;
1195 Ok(value)
1196}
1197
1198fn validate_timestamp_value(label: &str, value: &str) -> Result<()> {
1199 if value.ends_with('Z') && chrono::DateTime::parse_from_rfc3339(value).is_ok() {
1200 Ok(())
1201 } else {
1202 bail!("error invalid-sync-change {label} invalid-timestamp");
1203 }
1204}
1205
1206fn required_workspace_payload(payload: &Value) -> Result<()> {
1207 let id = required_string_payload("workspace_id", payload)?;
1208 if id.parse::<WorkspaceId>().is_err() {
1209 bail!("error invalid-sync-change workspace_id invalid-id");
1210 }
1211 required_string_payload("workspace_key", payload)?;
1212 Ok(())
1213}
1214
1215fn optional_workspace_payload(payload: &Value) -> Result<()> {
1216 if payload.get("workspace_id").is_none() && payload.get("workspace_key").is_none() {
1217 return Ok(());
1218 }
1219 required_workspace_payload(payload)
1220}
1221
1222fn optional_string_payload(key: &str, payload: &Value) -> Result<Option<String>> {
1223 match payload.get(key) {
1224 Some(Value::String(value)) => Ok(Some(value.clone())),
1225 Some(Value::Null) | None => Ok(None),
1226 Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1227 }
1228}
1229
1230fn optional_i64_payload(key: &str, payload: &Value) -> Result<Option<i64>> {
1231 match payload.get(key) {
1232 Some(Value::Number(value)) => value
1233 .as_i64()
1234 .map(Some)
1235 .with_context(|| format!("error invalid-sync-change payload.{key} invalid")),
1236 Some(Value::Null) | None => Ok(None),
1237 Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1238 }
1239}
1240
1241fn string_array_payload(key: &str, payload: &Value) -> Result<Vec<String>> {
1242 payload
1243 .get(key)
1244 .and_then(Value::as_array)
1245 .with_context(|| format!("error invalid-sync-change payload.{key} invalid"))?
1246 .iter()
1247 .map(|value| {
1248 value
1249 .as_str()
1250 .map(str::to_string)
1251 .with_context(|| format!("error invalid-sync-change payload.{key} invalid"))
1252 })
1253 .collect()
1254}
1255
1256fn optional_string_array_payload(key: &str, payload: &Value) -> Result<()> {
1257 match payload.get(key) {
1258 Some(Value::Array(values))
1259 if values
1260 .iter()
1261 .all(|value| value.as_str().is_some_and(|value| !value.trim().is_empty())) =>
1262 {
1263 Ok(())
1264 }
1265 Some(Value::Null) | None => Ok(()),
1266 Some(_) => bail!("error invalid-sync-change payload.{key} invalid"),
1267 }
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use super::*;
1273 use crate::change_log::{ChangePayload, op_type};
1274 use crate::workspaces::Workspace;
1275
1276 fn test_workspace() -> Workspace {
1277 Workspace {
1278 id: "0000000000000000".parse().unwrap(),
1279 key: "default".to_string(),
1280 name: "default".to_string(),
1281 }
1282 }
1283
1284 fn make_change_wire(
1285 op_type: &str,
1286 entity_type: &str,
1287 entity_id: &str,
1288 payload: serde_json::Value,
1289 ) -> ChangeWire {
1290 ChangeWire {
1291 change_id: "AAAAAAAAAAAAAAA0".to_string(),
1292 client_id: "client".to_string(),
1293 local_seq: 1,
1294 entity_type: entity_type.to_string(),
1295 entity_id: entity_id.to_string(),
1296 field: None,
1297 op_type: op_type.to_string(),
1298 payload,
1299 base_version: None,
1300 created_at: "2026-06-01T00:00:00Z".to_string(),
1301 server_seq: None,
1302 }
1303 }
1304
1305 #[test]
1306 fn sync_timestamps_accept_fractional_utc_precision() {
1307 validate_timestamp_value("created_at", "2026-07-28T06:41:44Z").unwrap();
1308 validate_timestamp_value("created_at", "2026-07-28T06:41:44.000000Z").unwrap();
1309 validate_timestamp_value("created_at", "2026-07-28T06:41:44.123456789Z").unwrap();
1310 }
1311
1312 #[test]
1313 fn sync_timestamps_reject_offsets_and_invalid_calendar_values() {
1314 for value in ["2026-07-28T08:41:44+02:00", "2026-13-28T06:41:44Z", "today"] {
1315 assert!(validate_timestamp_value("created_at", value).is_err());
1316 }
1317 }
1318
1319 #[test]
1320 fn recurrence_projection_rejects_nondeterministic_change_timestamp() {
1321 let workspace = test_workspace();
1322 let series_id: RecurrenceSeriesId = "AAAAAAAAAAAAAAAA".parse().unwrap();
1323 let schedule = RecurrenceSchedule::new(
1324 RecurrenceRule::daily(),
1325 "UTC".parse().unwrap(),
1326 "2026-07-20".parse().unwrap(),
1327 None,
1328 RecurrenceDuePolicy::SameDay,
1329 );
1330 let slot_on: NaiveDate = "2026-07-20".parse().unwrap();
1331 let identity =
1332 derive_occurrence_identity(&workspace.id, &series_id, &schedule, slot_on).unwrap();
1333 let payload = ChangePayload::workspace(&workspace)
1334 .set("series_id", series_id.as_str())
1335 .set("slot_on", slot_on.to_string())
1336 .set("task_id", identity.task_id.as_str())
1337 .set("projected_at", &identity.occurrence_link.projected_at)
1338 .set("task_change_id", &identity.task_change_id)
1339 .set("occurrence_change_id", &identity.occurrence_change_id)
1340 .set(
1341 "task_field_version_seed",
1342 &identity.field_version_seeds.task,
1343 )
1344 .set(
1345 "occurrence_field_version_seed",
1346 &identity.field_version_seeds.occurrence,
1347 )
1348 .set("frequency", "daily")
1349 .set("interval", 1)
1350 .set("weekdays", "")
1351 .set("timezone", "UTC")
1352 .set("start_on", "2026-07-20")
1353 .set("available_local_time", "")
1354 .set("due_policy", "same_day")
1355 .into_value();
1356 let mut change = make_change_wire(
1357 op_type::PROJECT_RECURRENCE_OCCURRENCE,
1358 "recurrence_series",
1359 series_id.as_str(),
1360 payload,
1361 );
1362 change.change_id = identity.occurrence_change_id;
1363 change.field = Some("projection".to_string());
1364 change.created_at = identity.occurrence_link.projected_at;
1365 validate_pushed_change(&change).unwrap();
1366
1367 change.created_at = "2026-07-20T00:00:01Z".to_string();
1368 assert!(
1369 validate_pushed_change(&change)
1370 .unwrap_err()
1371 .to_string()
1372 .contains("recurrence-deterministic-mismatch")
1373 );
1374 }
1375
1376 #[test]
1377 fn request_pull_limit_has_default_and_bounds() {
1378 assert_eq!(request_pull_limit(None).unwrap(), MAX_PULL_BATCH);
1379 assert!(request_pull_limit(Some(MAX_PULL_BATCH)).is_ok());
1380 assert_eq!(
1381 request_pull_limit(Some(0)).unwrap_err().to_string(),
1382 "error sync-pull-limit-out-of-range min=1 max=512 got=0"
1383 );
1384 assert_eq!(
1385 request_pull_limit(Some(MAX_PULL_BATCH + 1))
1386 .unwrap_err()
1387 .to_string(),
1388 "error sync-pull-limit-out-of-range min=1 max=512 got=513"
1389 );
1390 }
1391
1392 #[test]
1393 fn request_envelope_rejects_negative_cursor_and_oversized_push_batch() {
1394 let request = SyncRequest {
1395 protocol_version: Some(SYNC_PROTOCOL_VERSION),
1396 client_id: "test-client".to_string(),
1397 after: -1,
1398 pull_limit: Some(MAX_PULL_BATCH),
1399 changes: Vec::new(),
1400 };
1401 assert_eq!(
1402 validate_sync_request_envelope(&request)
1403 .unwrap_err()
1404 .to_string(),
1405 "error sync-after-out-of-range min=0 got=-1"
1406 );
1407
1408 let request = SyncRequest {
1409 protocol_version: Some(SYNC_PROTOCOL_VERSION),
1410 client_id: "test-client".to_string(),
1411 after: 0,
1412 pull_limit: Some(MAX_PULL_BATCH),
1413 changes: vec![
1414 ChangeWire {
1415 change_id: "AAAAAAAAAAAAAAA0".to_string(),
1416 client_id: "client".to_string(),
1417 local_seq: 1,
1418 entity_type: "task".to_string(),
1419 entity_id: "BBBBBBBBBBBBBBBB".to_string(),
1420 field: None,
1421 op_type: "create_task".to_string(),
1422 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"}),
1423 base_version: None,
1424 created_at: "2026-01-01T00:00:00Z".to_string(),
1425 server_seq: None,
1426 };
1427 MAX_PUSH_BATCH + 1
1428 ],
1429 };
1430 assert_eq!(
1431 validate_sync_request_envelope(&request)
1432 .unwrap_err()
1433 .to_string(),
1434 "error sync-push-too-large limit=256 got=257"
1435 );
1436 }
1437
1438 #[test]
1439 fn response_validation_respects_request_pull_limit() {
1440 let response = SyncResponse {
1441 protocol_version: SYNC_PROTOCOL_VERSION,
1442 cursor: 1,
1443 has_more: false,
1444 push_acks: vec![],
1445 changes: vec![
1446 ChangeWire {
1447 change_id: "AAAAAAAAAAAAAAA1".to_string(),
1448 client_id: "client".to_string(),
1449 local_seq: 1,
1450 entity_type: "task".to_string(),
1451 entity_id: "BBBBBBBBBBBBBBBB".to_string(),
1452 field: None,
1453 op_type: "create_task".to_string(),
1454 payload: serde_json::json!({
1455 "title":"one",
1456 "project_id":"0000000000000000",
1457 "project_key":"app",
1458 "project_name":"app",
1459 "project_prefix":"APP",
1460 }),
1461 base_version: None,
1462 created_at: "2026-01-01T00:00:00Z".to_string(),
1463 server_seq: Some(1),
1464 };
1465 MAX_PULL_BATCH as usize + 1
1466 ],
1467 };
1468 assert_eq!(
1469 validate_sync_response_for_request(0, MAX_PULL_BATCH, &[], &response)
1470 .unwrap_err()
1471 .to_string(),
1472 "error invalid-sync-response pull-too-large limit=512 got=513"
1473 );
1474 }
1475
1476 #[test]
1477 fn constructed_create_task_payload_passes_wire_validation() {
1478 let ws = test_workspace();
1479 let payload = ChangePayload::workspace(&ws)
1480 .set("title", "test task")
1481 .set("description", "a description")
1482 .set("project_id", "1111111111111111")
1483 .set("project_key", "app")
1484 .set("project_name", "App")
1485 .set("project_prefix", "APP")
1486 .set("status", "inbox")
1487 .set("priority", "none")
1488 .set("created_at", "2026-06-01T00:00:00Z")
1489 .into_value();
1490 let change = make_change_wire(op_type::CREATE_TASK, "task", "BBBBBBBBBBBBBBBB", payload);
1491 validate_pushed_change(&change)
1492 .expect("create_task payload built with ChangePayload should be wire-valid");
1493 }
1494
1495 #[test]
1496 fn constructed_create_project_payload_passes_wire_validation() {
1497 let ws = test_workspace();
1498 let payload = ChangePayload::workspace(&ws)
1499 .set("key", "app")
1500 .set("name", "App")
1501 .set("prefix", "APP")
1502 .set("created_at", "2026-06-01T00:00:00Z")
1503 .into_value();
1504 let change = make_change_wire(
1505 op_type::CREATE_PROJECT,
1506 "project",
1507 "1111111111111111",
1508 payload,
1509 );
1510 validate_pushed_change(&change)
1511 .expect("create_project payload built with ChangePayload should be wire-valid");
1512 }
1513
1514 #[test]
1515 fn project_changes_reject_invalid_project_ids() {
1516 let ws = test_workspace();
1517 let project_payload = ChangePayload::workspace(&ws)
1518 .set("key", "app")
1519 .set("name", "App")
1520 .set("prefix", "APP")
1521 .set("created_at", "2026-06-01T00:00:00Z")
1522 .into_value();
1523 let create_project = make_change_wire(
1524 op_type::CREATE_PROJECT,
1525 "project",
1526 "invalid",
1527 project_payload,
1528 );
1529 assert_eq!(
1530 validate_pushed_change(&create_project)
1531 .unwrap_err()
1532 .to_string(),
1533 "error invalid-sync-change entity_id invalid-id"
1534 );
1535
1536 let task_payload = ChangePayload::workspace(&ws)
1537 .set("title", "test task")
1538 .set("project_id", "invalid")
1539 .set("project_key", "app")
1540 .set("project_name", "App")
1541 .set("project_prefix", "APP")
1542 .set("created_at", "2026-06-01T00:00:00Z")
1543 .into_value();
1544 let create_task = make_change_wire(
1545 op_type::CREATE_TASK,
1546 "task",
1547 "BBBBBBBBBBBBBBBB",
1548 task_payload,
1549 );
1550 assert_eq!(
1551 validate_pushed_change(&create_task)
1552 .unwrap_err()
1553 .to_string(),
1554 "error invalid-sync-change project_id invalid-id"
1555 );
1556
1557 let field_payload = ChangePayload::workspace(&ws)
1558 .set("value", "invalid")
1559 .set("project_id", "invalid")
1560 .set("project_key", "app")
1561 .set("project_name", "App")
1562 .set("project_prefix", "APP")
1563 .into_value();
1564 let mut set_field = make_change_wire(
1565 op_type::SET_FIELD,
1566 "task",
1567 "BBBBBBBBBBBBBBBB",
1568 field_payload,
1569 );
1570 set_field.field = Some("project".to_string());
1571 assert_eq!(
1572 validate_pushed_change(&set_field).unwrap_err().to_string(),
1573 "error invalid-sync-change project_id invalid-id"
1574 );
1575 }
1576
1577 #[test]
1578 fn constructed_label_add_payload_passes_wire_validation() {
1579 let ws = test_workspace();
1580 let payload = ChangePayload::workspace(&ws)
1581 .set("label", "bug")
1582 .into_value();
1583 let mut change = make_change_wire(op_type::LABEL_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1584 change.field = Some("labels".to_string());
1585 validate_pushed_change(&change)
1586 .expect("label_add payload built with ChangePayload should be wire-valid");
1587 }
1588
1589 #[test]
1590 fn constructed_label_administration_payloads_pass_wire_validation() {
1591 let ws = test_workspace();
1592 let rename = make_change_wire(
1593 op_type::SET_LABEL_NAME,
1594 "label",
1595 "old",
1596 ChangePayload::workspace(&ws)
1597 .set("name", "old")
1598 .set("new_name", "new")
1599 .set("renamed_at", "2026-06-01T00:00:00Z")
1600 .into_value(),
1601 );
1602 validate_pushed_change(&rename).unwrap();
1603
1604 let restore = make_change_wire(
1605 op_type::LABEL_RESTORE,
1606 "label",
1607 "new",
1608 ChangePayload::workspace(&ws)
1609 .set("name", "new")
1610 .set("created_at", "2026-06-01T00:00:00Z")
1611 .set("task_ids", ["BBBBBBBBBBBBBBBB"])
1612 .set("series_ids", ["CCCCCCCCCCCCCCCC"])
1613 .set("restored_at", "2026-06-01T00:00:01Z")
1614 .into_value(),
1615 );
1616 validate_pushed_change(&restore).unwrap();
1617 }
1618
1619 #[test]
1620 fn constructed_dependency_add_payload_passes_wire_validation() {
1621 let ws = test_workspace();
1622 let payload = ChangePayload::workspace(&ws)
1623 .set("depends_on_task_id", "CCCCCCCCCCCCCCCC")
1624 .into_value();
1625 let mut change =
1626 make_change_wire(op_type::DEPENDENCY_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1627 change.field = Some("dependencies".to_string());
1628 validate_pushed_change(&change)
1629 .expect("dependency_add payload built with ChangePayload should be wire-valid");
1630 }
1631
1632 #[test]
1633 fn constructed_note_edit_payload_passes_wire_validation() {
1634 let ws = test_workspace();
1635 let payload = ChangePayload::workspace(&ws)
1636 .set("note_id", "DDDDDDDDDDDDDDDD")
1637 .set("body", "corrected note body")
1638 .set("edited_at", "2026-06-01T01:00:00Z")
1639 .into_value();
1640 let mut change = make_change_wire(op_type::NOTE_EDIT, "task", "BBBBBBBBBBBBBBBB", payload);
1641 change.field = Some("notes".to_string());
1642 validate_pushed_change(&change)
1643 .expect("note_edit payload built with ChangePayload should be wire-valid");
1644 }
1645
1646 #[test]
1647 fn constructed_note_add_payload_passes_wire_validation() {
1648 let ws = test_workspace();
1649 let payload = ChangePayload::workspace(&ws)
1650 .set("note_id", "DDDDDDDDDDDDDDDD")
1651 .set("body", "note body")
1652 .set("created_at", "2026-06-01T00:00:00Z")
1653 .into_value();
1654 let mut change = make_change_wire(op_type::NOTE_ADD, "task", "BBBBBBBBBBBBBBBB", payload);
1655 change.field = Some("notes".to_string());
1656 validate_pushed_change(&change)
1657 .expect("note_add payload built with ChangePayload should be wire-valid");
1658 }
1659}