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