1use crate::workspace::manifest::LocalWorkspaceManifestSnapshot;
17use serde::{Deserialize, Serialize};
18use thiserror::Error;
19
20pub const WORKSPACE_SOURCE_SNAPSHOT_SCHEMA_V1: &str = "a3s.code.workspace-source-snapshot.v1";
21pub const WORKSPACE_SOURCE_SNAPSHOT_DIGEST_DOMAIN_V1: &str =
22 "a3s.code.workspace-source-snapshot.identity.v1";
23pub const WORKSPACE_SOURCE_CONTENT_DOMAIN_V1: &str =
25 "a3s.code.workspace-source-snapshot.content.v1";
26const MAX_ROOT_BYTES: usize = 1024;
27const MAX_ELIGIBLE_FILES: u64 = u32::MAX as u64;
28
29#[derive(Debug, Clone, PartialEq, Eq, Error)]
30pub enum WorkspaceSourceSnapshotError {
31 #[error("workspace source snapshot schema is unsupported")]
32 UnsupportedSchema,
33 #[error("workspace source snapshot field `{0}` is invalid")]
34 InvalidField(&'static str),
35 #[error("workspace source snapshot digest `{0}` is invalid")]
36 InvalidDigest(&'static str),
37 #[error("workspace source snapshot serialization failed: {0}")]
38 Serialization(String),
39}
40
41#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
43#[serde(rename_all = "camelCase", deny_unknown_fields)]
44pub struct WorkspaceSourceSnapshotV1 {
45 pub schema: String,
46 pub root: String,
48 pub manifest_version: u64,
50 pub eligible_files: u64,
52 pub content_digest: String,
55 pub eligibility_revision: u64,
57 pub document_revision: u64,
59 pub catalog_revision: u64,
61 pub index_generation: u64,
63 pub observed_at_ms: u64,
65 pub snapshot_digest: String,
67}
68
69impl WorkspaceSourceSnapshotV1 {
70 #[allow(clippy::too_many_arguments)]
71 pub fn new(
72 root: impl Into<String>,
73 manifest_version: u64,
74 eligible_files: u64,
75 content_digest: impl Into<String>,
76 eligibility_revision: u64,
77 document_revision: u64,
78 catalog_revision: u64,
79 index_generation: u64,
80 observed_at_ms: u64,
81 ) -> Result<Self, WorkspaceSourceSnapshotError> {
82 let mut snapshot = Self {
83 schema: WORKSPACE_SOURCE_SNAPSHOT_SCHEMA_V1.to_owned(),
84 root: root.into(),
85 manifest_version,
86 eligible_files,
87 content_digest: content_digest.into(),
88 eligibility_revision,
89 document_revision,
90 catalog_revision,
91 index_generation,
92 observed_at_ms,
93 snapshot_digest: String::new(),
94 };
95 snapshot.validate_without_digest()?;
96 snapshot.snapshot_digest = snapshot.expected_digest()?;
97 Ok(snapshot)
98 }
99
100 pub fn from_manifest(
108 manifest: &LocalWorkspaceManifestSnapshot,
109 content_digest: impl Into<String>,
110 eligibility_revision: u64,
111 document_revision: u64,
112 catalog_revision: u64,
113 index_generation: u64,
114 ) -> Result<Self, WorkspaceSourceSnapshotError> {
115 Self::new(
116 manifest.root.display().to_string(),
117 manifest.version,
118 u64::try_from(manifest.files.len())
119 .map_err(|_| WorkspaceSourceSnapshotError::InvalidField("eligible_files"))?,
120 content_digest,
121 eligibility_revision,
122 document_revision,
123 catalog_revision,
124 index_generation,
125 manifest.scanned_at_ms,
126 )
127 }
128
129 pub fn validate(&self) -> Result<(), WorkspaceSourceSnapshotError> {
130 self.validate_without_digest()?;
131 validate_digest("snapshot_digest", &self.snapshot_digest)?;
132 if self.snapshot_digest != self.expected_digest()? {
133 return Err(WorkspaceSourceSnapshotError::InvalidDigest(
134 "snapshot_digest",
135 ));
136 }
137 Ok(())
138 }
139
140 fn expected_digest(&self) -> Result<String, WorkspaceSourceSnapshotError> {
141 #[derive(Serialize)]
142 struct Identity<'a> {
143 schema: &'a str,
144 root: &'a str,
145 manifest_version: u64,
146 eligible_files: u64,
147 content_digest: &'a str,
148 eligibility_revision: u64,
149 document_revision: u64,
150 catalog_revision: u64,
151 index_generation: u64,
152 observed_at_ms: u64,
153 }
154 let bytes = serde_json::to_vec(&Identity {
155 schema: &self.schema,
156 root: &self.root,
157 manifest_version: self.manifest_version,
158 eligible_files: self.eligible_files,
159 content_digest: &self.content_digest,
160 eligibility_revision: self.eligibility_revision,
161 document_revision: self.document_revision,
162 catalog_revision: self.catalog_revision,
163 index_generation: self.index_generation,
164 observed_at_ms: self.observed_at_ms,
165 })
166 .map_err(|error| WorkspaceSourceSnapshotError::Serialization(error.to_string()))?;
167 Ok(digest_bytes(
168 WORKSPACE_SOURCE_SNAPSHOT_DIGEST_DOMAIN_V1,
169 &bytes,
170 ))
171 }
172
173 fn validate_without_digest(&self) -> Result<(), WorkspaceSourceSnapshotError> {
174 if self.schema != WORKSPACE_SOURCE_SNAPSHOT_SCHEMA_V1 {
175 return Err(WorkspaceSourceSnapshotError::UnsupportedSchema);
176 }
177 if self.root.is_empty()
178 || self.root.len() > MAX_ROOT_BYTES
179 || self.root.contains('\0')
180 || self.root.lines().count() != 1
181 {
182 return Err(WorkspaceSourceSnapshotError::InvalidField("root"));
183 }
184 if self.eligible_files > MAX_ELIGIBLE_FILES {
185 return Err(WorkspaceSourceSnapshotError::InvalidField("eligible_files"));
186 }
187 validate_digest("content_digest", &self.content_digest)?;
188 if self.observed_at_ms == 0 {
189 return Err(WorkspaceSourceSnapshotError::InvalidField("observed_at_ms"));
190 }
191 Ok(())
192 }
193
194 pub fn revision_at_least(&self, other: &Self) -> bool {
199 self.manifest_version >= other.manifest_version
200 && self.eligibility_revision >= other.eligibility_revision
201 && self.document_revision >= other.document_revision
202 && self.catalog_revision >= other.catalog_revision
203 && self.index_generation >= other.index_generation
204 }
205
206 pub fn is_current_against(&self, live: &Self) -> bool {
216 self.root == live.root
217 && self.manifest_version == live.manifest_version
218 && self.content_digest == live.content_digest
219 && self.eligibility_revision == live.eligibility_revision
220 && self.catalog_revision <= live.catalog_revision
221 }
222
223 pub fn is_stale_against(&self, live: &Self) -> bool {
225 !self.is_current_against(live)
226 }
227}
228
229impl<'de> Deserialize<'de> for WorkspaceSourceSnapshotV1 {
230 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
231 where
232 D: serde::Deserializer<'de>,
233 {
234 #[derive(Deserialize)]
235 #[serde(rename_all = "camelCase", deny_unknown_fields)]
236 struct Wire {
237 schema: String,
238 root: String,
239 manifest_version: u64,
240 eligible_files: u64,
241 content_digest: String,
242 eligibility_revision: u64,
243 document_revision: u64,
244 catalog_revision: u64,
245 index_generation: u64,
246 observed_at_ms: u64,
247 snapshot_digest: String,
248 }
249
250 let wire = Wire::deserialize(deserializer)?;
251 let value = Self {
252 schema: wire.schema,
253 root: wire.root,
254 manifest_version: wire.manifest_version,
255 eligible_files: wire.eligible_files,
256 content_digest: wire.content_digest,
257 eligibility_revision: wire.eligibility_revision,
258 document_revision: wire.document_revision,
259 catalog_revision: wire.catalog_revision,
260 index_generation: wire.index_generation,
261 observed_at_ms: wire.observed_at_ms,
262 snapshot_digest: wire.snapshot_digest,
263 };
264 value.validate().map_err(serde::de::Error::custom)?;
265 Ok(value)
266 }
267}
268
269pub fn workspace_content_digest(manifest: &LocalWorkspaceManifestSnapshot) -> String {
276 let mut entries: Vec<(&str, u64, Option<u64>)> = manifest
277 .files
278 .iter()
279 .map(|file| (file.path.as_str(), file.size, file.modified_ms))
280 .collect();
281 entries.sort_unstable_by(|left, right| left.0.cmp(right.0));
282 let mut bytes = Vec::new();
283 for (path, size, modified_ms) in entries {
284 bytes.extend_from_slice(path.as_bytes());
285 bytes.push(0);
286 bytes.extend_from_slice(&size.to_le_bytes());
287 bytes.push(0);
288 bytes.extend_from_slice(&modified_ms.unwrap_or(0).to_le_bytes());
289 bytes.push(0);
290 }
291 digest_bytes(WORKSPACE_SOURCE_CONTENT_DOMAIN_V1, &bytes)
292}
293
294fn validate_digest(field: &'static str, value: &str) -> Result<(), WorkspaceSourceSnapshotError> {
295 if value.len() != 71
296 || !value.starts_with("sha256:")
297 || !value[7..]
298 .bytes()
299 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
300 {
301 return Err(WorkspaceSourceSnapshotError::InvalidDigest(field));
302 }
303 Ok(())
304}
305
306fn digest_bytes(domain: &str, bytes: &[u8]) -> String {
307 use sha2::{Digest, Sha256};
308 let mut hasher = Sha256::new();
309 hasher.update(domain.as_bytes());
310 hasher.update([0]);
311 hasher.update(bytes);
312 format!("sha256:{:x}", hasher.finalize())
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318 use crate::workspace::manifest::{LocalWorkspaceFile, LocalWorkspaceFileStatus};
319
320 fn manifest(version: u64, files: Vec<LocalWorkspaceFile>) -> LocalWorkspaceManifestSnapshot {
321 LocalWorkspaceManifestSnapshot {
322 version,
323 root: std::path::PathBuf::from("/tmp/ws"),
324 files,
325 scanned_at_ms: 1_000 + version,
326 }
327 }
328
329 fn file(path: &str, size: u64) -> LocalWorkspaceFile {
330 LocalWorkspaceFile {
331 path: path.to_owned(),
332 size,
333 modified_ms: Some(42),
334 language: Some("rust".to_owned()),
335 status: LocalWorkspaceFileStatus::Tracked,
336 binary: false,
337 generated: false,
338 }
339 }
340
341 fn snapshot(manifest_version: u64) -> WorkspaceSourceSnapshotV1 {
342 let m = manifest(manifest_version, vec![file("src/main.rs", 10)]);
343 WorkspaceSourceSnapshotV1::from_manifest(&m, workspace_content_digest(&m), 1, 2, 3, 4)
344 .unwrap()
345 }
346
347 #[test]
348 fn identity_is_deterministic_and_order_independent() {
349 let a = manifest(7, vec![file("a.rs", 1), file("b.rs", 2)]);
350 let b = manifest(7, vec![file("b.rs", 2), file("a.rs", 1)]);
351 assert_eq!(workspace_content_digest(&a), workspace_content_digest(&b));
352
353 let first =
354 WorkspaceSourceSnapshotV1::from_manifest(&a, workspace_content_digest(&a), 1, 2, 3, 4)
355 .unwrap();
356 assert!(first.validate().is_ok());
357 let encoded = serde_json::to_string(&first).unwrap();
358 let decoded: WorkspaceSourceSnapshotV1 = serde_json::from_str(&encoded).unwrap();
359 assert_eq!(decoded, first);
360
361 let mut unknown = serde_json::to_value(&first).unwrap();
362 unknown
363 .as_object_mut()
364 .unwrap()
365 .insert("future".to_owned(), serde_json::json!(true));
366 assert!(serde_json::from_value::<WorkspaceSourceSnapshotV1>(unknown).is_err());
367 }
368
369 #[test]
370 fn tampering_fails_closed() {
371 let mut forged = snapshot(7);
372 forged.catalog_revision = 99;
373 assert!(forged.validate().is_err());
374 }
375
376 #[test]
377 fn stale_results_cannot_cross_a_source_boundary() {
378 let base = snapshot(7);
379 let rebuilt = WorkspaceSourceSnapshotV1::from_manifest(
381 &manifest(7, vec![file("src/main.rs", 10)]),
382 workspace_content_digest(&manifest(7, vec![file("src/main.rs", 10)])),
383 1,
384 2,
385 3,
386 9,
387 )
388 .unwrap();
389 assert!(base.is_current_against(&rebuilt));
390 assert!(!base.is_stale_against(&rebuilt));
391
392 let advanced = snapshot(8);
394 assert!(base.is_stale_against(&advanced));
395 assert!(!base.is_current_against(&advanced));
396
397 let changed = {
399 let m = manifest(7, vec![file("src/main.rs", 11)]);
400 WorkspaceSourceSnapshotV1::from_manifest(&m, workspace_content_digest(&m), 1, 2, 3, 4)
401 .unwrap()
402 };
403 assert!(base.is_stale_against(&changed));
404
405 assert!(rebuilt.revision_at_least(&base));
407 assert!(!base.revision_at_least(&advanced));
408 }
409
410 #[test]
411 fn roots_cannot_be_confused() {
412 let other_root = {
413 let m = manifest(7, vec![file("src/main.rs", 10)]);
414 let mut s = WorkspaceSourceSnapshotV1::from_manifest(
415 &m,
416 workspace_content_digest(&m),
417 1,
418 2,
419 3,
420 4,
421 )
422 .unwrap();
423 s.root = "/tmp/other".to_owned();
424 s.snapshot_digest = s.expected_digest().unwrap();
425 s
426 };
427 assert!(snapshot(7).is_stale_against(&other_root));
428 }
429}
430
431#[cfg(test)]
432mod qualification {
433 use super::*;
434 use crate::workspace::manifest::{LocalWorkspaceFile, LocalWorkspaceFileStatus};
435 use crate::workspace::retrieval::{
436 ChunkCatalogLimits, ChunkingConfig, WorkspaceChunkCatalog, WorkspaceIndexError,
437 };
438 use crate::workspace::WorkspacePath;
439
440 fn catalog() -> std::sync::Arc<WorkspaceChunkCatalog> {
441 WorkspaceChunkCatalog::new(
442 ChunkingConfig::default(),
443 ChunkCatalogLimits {
444 max_files: 64,
445 max_chunks: 512,
446 max_text_bytes: 1024 * 1024,
447 max_index_bytes: 8 * 1024 * 1024,
448 },
449 )
450 .unwrap()
451 }
452
453 fn manifest_for(
454 catalog_snapshot: &crate::workspace::retrieval::ChunkCatalogSnapshot,
455 ) -> LocalWorkspaceManifestSnapshot {
456 let files = catalog_snapshot
457 .paths()
458 .into_iter()
459 .map(|path| LocalWorkspaceFile {
460 path,
461 size: 32,
462 modified_ms: Some(7),
463 language: Some("rust".to_owned()),
464 status: LocalWorkspaceFileStatus::Tracked,
465 binary: false,
466 generated: false,
467 })
468 .collect();
469 LocalWorkspaceManifestSnapshot {
470 version: catalog_snapshot.source_revision(),
471 root: std::path::PathBuf::from("/tmp/ws"),
472 files,
473 scanned_at_ms: 10_000,
474 }
475 }
476
477 #[test]
482 fn retrieval_results_trace_to_one_snapshot_and_edits_invalidate() {
483 let catalog = catalog();
484 let path = WorkspacePath::from_normalized("src/main.rs");
485 catalog
486 .replace_file(&path, Some("rust"), 1, "fn main() {}\n")
487 .unwrap();
488 let admitted = catalog.snapshot().unwrap();
489 let manifest = manifest_for(&admitted);
490 let snapshot = WorkspaceSourceSnapshotV1::from_manifest(
491 &manifest,
492 workspace_content_digest(&manifest),
493 1,
494 0,
495 admitted.revision(),
496 1,
497 )
498 .unwrap();
499
500 let chunk_digest = admitted
503 .content_digest(&path)
504 .expect("admitted file carries a content digest");
505 assert!(chunk_digest.starts_with("sha256:"));
506 assert_eq!(admitted.revision(), snapshot.catalog_revision);
507 assert_eq!(admitted.source_revision(), snapshot.manifest_version);
508 assert_eq!(
509 admitted.eligible_file_count() as u64,
510 snapshot.eligible_files
511 );
512
513 let rebuilt = WorkspaceSourceSnapshotV1::from_manifest(
515 &manifest,
516 workspace_content_digest(&manifest),
517 1,
518 0,
519 admitted.revision(),
520 2,
521 )
522 .unwrap();
523 assert!(snapshot.is_current_against(&rebuilt));
524
525 catalog
528 .replace_file(&path, Some("rust"), 2, "fn main() { changed }\n")
529 .unwrap();
530 let edited = catalog.snapshot().unwrap();
531 assert!(edited.source_revision() > admitted.source_revision());
532 let edited_manifest = manifest_for(&edited);
533 let live = WorkspaceSourceSnapshotV1::from_manifest(
534 &edited_manifest,
535 workspace_content_digest(&edited_manifest),
536 1,
537 0,
538 edited.revision(),
539 1,
540 )
541 .unwrap();
542 assert!(snapshot.is_stale_against(&live));
543 assert!(live.revision_at_least(&snapshot));
544
545 let _ = std::any::type_name::<WorkspaceIndexError>();
546 }
547}