1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
use std::path::PathBuf;
use crate::checkpoint::checkpoint_durability;
use crate::context::AppContext;
use crate::protocol::{RawRequest, Response};
/// Handle the `checkpoint` command: create a named workspace checkpoint.
///
/// Params:
/// - `name` (string, required) — checkpoint name.
/// - `files` (array of strings, optional) — files to include. If omitted, uses
/// all files tracked by the backup store.
///
/// Returns: `{ name, file_count, created_at, storage_path, durability }`.
/// `storage_path` names the durable on-disk directory callers can inspect.
/// Explicit files are read directly, including untracked or gitignored files. When any requested
/// file cannot be read, `file_count` includes only restorable snapshots and the
/// response adds `skipped: [{ file, error }, ...]` for every omitted path.
pub fn handle_checkpoint(req: &RawRequest, ctx: &AppContext) -> Response {
match handle_checkpoint_impl(req, ctx) {
Ok(resp) | Err(resp) => resp,
}
}
fn handle_checkpoint_impl(req: &RawRequest, ctx: &AppContext) -> Result<Response, Response> {
let name = match req.params.get("name").and_then(|v| v.as_str()) {
Some(n) => n,
None => {
return Ok(Response::error(
&req.id,
"invalid_request",
"checkpoint: missing required param 'name'",
));
}
};
let files: Vec<PathBuf> = req
.params
.get("files")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(PathBuf::from))
.collect()
})
.unwrap_or_default();
let file_list = if files.is_empty() {
let backup = ctx.backup().lock();
backup.tracked_files(req.session())
} else {
files
};
let validated_files = validate_checkpoint_files(&req.id, ctx, file_list)?;
let backup = ctx.backup().lock();
let mut checkpoint_store = ctx.checkpoint().lock();
match checkpoint_store.create(req.session(), name, validated_files, &backup) {
Ok(info) => {
let storage_path = info
.storage_path
.as_deref()
.map(|path| path.display().to_string())
.unwrap_or_default();
// Only surface `skipped` when we actually skipped something. Keeps
// happy-path responses compact and backward-compatible for callers
// that only read `name` / `file_count` / `created_at`.
let mut payload = serde_json::json!({
"name": info.name,
"file_count": info.file_count,
"created_at": info.created_at,
"storage_path": storage_path,
"durability": checkpoint_durability(std::path::Path::new(&storage_path)),
});
if !info.evicted.is_empty() {
payload["evicted"] = serde_json::json!(info.evicted);
}
if !info.skipped.is_empty() {
let skipped: Vec<_> = info
.skipped
.iter()
.map(|(p, err)| {
serde_json::json!({
"file": p.display().to_string(),
"error": err,
})
})
.collect();
payload["skipped"] = serde_json::Value::Array(skipped);
}
Ok(Response::success(&req.id, payload))
}
Err(e) => Ok(Response::error(&req.id, e.code(), e.to_string())),
}
}
fn validate_checkpoint_files(
req_id: &str,
ctx: &AppContext,
files: Vec<PathBuf>,
) -> Result<Vec<PathBuf>, Response> {
let mut validated = Vec::with_capacity(files.len());
for path in files {
// Resolve relative paths against the bound project root so the
// checkpoint key matches the path the mutating tool recorded. A relative
// path passed straight to `canonicalize_key` would be joined against the
// daemon's cwd and miss the snapshot.
let input = ctx.resolve_relative_path(&path);
// Creation and restore must authorize and key the same final object.
// Resolving only ancestors preserves a final symlink for the snapshot
// reader while still rejecting symlinked parents that escape the root.
validated.push(ctx.validate_write_location(req_id, &input)?);
}
Ok(validated)
}
/// Handle the `checkpoint_paths` command: return paths a checkpoint restore would write.
///
/// Params: `name` (string, required) — checkpoint name.
/// Returns: `{ name, paths, file_count }` without mutating checkpoint or filesystem state.
pub fn handle_checkpoint_paths(req: &RawRequest, ctx: &AppContext) -> Response {
let name = match req.params.get("name").and_then(|v| v.as_str()) {
Some(n) => n,
None => {
return Response::error(
&req.id,
"invalid_request",
"checkpoint_paths: missing required param 'name'",
);
}
};
let mut checkpoint_store = ctx.checkpoint().lock();
match checkpoint_store.absolute_file_paths(req.session(), name) {
Ok(paths) => Response::success(
&req.id,
serde_json::json!({
"name": name,
"paths": paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>(),
"file_count": paths.len(),
}),
),
Err(e) => Response::error(&req.id, e.code(), e.to_string()),
}
}