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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
//! What:
//! The append-only install log inside one transaction's private stage:
//! one synced JSON line per batch of intents and one per batch of proven creations.
//! Why:
//! The journal header changes only with the phase, so the per-batch records live here;
//! bytes written then grow with the entry count instead of with the square of it.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! await using opened = await openInstallLog(stageContainer);
//! await opened.log.appendIntents(paths);
//! ```
use super::json_record::quote;
use super::private_storage::sync_directory;
use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
use super::worktree_copy_install_entry::InstalledPath;
use super::worktree_copy_journal::{
Role, assert_private_path, encode_installed_entry, installed_entry, string_array,
};
use super::worktree_copy_selection::assert_safe_repository_path;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
/// What:
/// The install-log file name inside a stage container.
/// Why:
/// Recovery finds the log by this name, so both wrappers must agree on it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const INSTALL_LOG_NAME = 'install-log.jsonl';
/// ```
pub const INSTALL_LOG_NAME: &str = "install-log.jsonl";
/// What:
/// The records an existing log holds.
/// Why:
/// An interrupted installation resumes from exactly these claims and creations.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type InstallLogRecords = { intendedEntries: string[]; createdEntries: InstalledWorktreePath[] };
/// ```
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct InstallLogRecords {
/// Selected paths claimed before destination mutation, in claim order.
pub intended: Vec<String>,
/// Paths proven created, in creation order.
pub created: Vec<InstalledPath>,
}
/// What:
/// The append handle of one log.
/// Why:
/// Each append is synced before the caller mutates the destination, so a crash leaves the claim on disk.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type InstallLog = { appendIntents(paths): Promise<void>; appendCreations(entries): Promise<void> };
/// ```
#[derive(Debug)]
pub struct InstallLog {
/// The log file, opened for appending without following links.
file: std::fs::File,
/// The log's path, named in every diagnostic.
path: PathBuf,
}
/// What:
/// An open log together with what it already held.
/// Why:
/// The caller seeds its claim and creation sets from the recorded part before it installs anything.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// { log: InstallLog; recorded: InstallLogRecords }
/// ```
#[derive(Debug)]
pub struct OpenedLog {
/// The append handle.
pub log: InstallLog,
/// The records already in the log.
pub recorded: InstallLogRecords,
}
/// What:
/// One line's records.
/// Why:
/// A line carries either intents or creations, never both.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function parseLine({ line, path }): InstallLogRecords;
/// ```
fn parse_line(line: &str, path: &Path) -> Result<InstallLogRecords, WorktreeCopyError> {
let corrupt = || {
return failure_with_cause(
format!(
"cli-git: worktree-copy install log is corrupt: {}.",
quote(path.to_string_lossy().as_ref())
)
.as_str(),
None,
);
};
let value: serde_json::Value = serde_json::from_str(line).map_err(|_| return corrupt())?;
let object = value.as_object().ok_or_else(corrupt)?;
if let Some(intended) = object.get("intended").and_then(serde_json::Value::as_array) {
let names: Vec<String> = intended
.iter()
.map(|item| return item.as_str().map(str::to_string))
.collect::<Option<Vec<String>>>()
.ok_or_else(corrupt)?;
for name in &names {
assert_safe_repository_path(name.as_str())?;
}
return Ok(InstallLogRecords {
intended: names,
created: Vec::new(),
});
}
if let Some(created) = object.get("created").and_then(serde_json::Value::as_array) {
let entries: Vec<InstalledPath> = created
.iter()
.map(installed_entry)
.collect::<Option<Vec<InstalledPath>>>()
.ok_or_else(corrupt)?;
for entry in &entries {
assert_safe_repository_path(entry.relative_path.as_str())?;
}
return Ok(InstallLogRecords {
intended: Vec::new(),
created: entries,
});
}
return Err(corrupt());
}
/// What:
/// The records of every complete line, and the byte length those lines span.
/// Why:
/// A trailing fragment without its line feed is an append the owner never finished; it is not a record.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function readCompleteRecords(path): Promise<{ records; completeLength; totalLength }>;
/// ```
fn read_complete_records(path: &Path) -> Result<(InstallLogRecords, u64, u64), WorktreeCopyError> {
assert_private_path(path, Role::JournalFile)?;
let content: Vec<u8> = std::fs::read(path).map_err(|error| return raw_failure(&error))?;
let complete_length: usize = content
.iter()
.rposition(|byte| return *byte == b'\n')
.map_or(0, |index| return index + 1);
let text: String = String::from_utf8_lossy(&content[..complete_length]).into_owned();
let mut records: InstallLogRecords = InstallLogRecords::default();
for line in text.split('\n').filter(|line| return !line.is_empty()) {
let parsed: InstallLogRecords = parse_line(line, path)?;
records.intended.extend(parsed.intended);
records.created.extend(parsed.created);
}
return Ok((records, complete_length as u64, content.len() as u64));
}
/// What:
/// Opens the log of one stage, returning what it already records and truncating any unfinished append.
/// Why:
/// Resumption must see every claim and creation, and new records must start on a fresh line.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await using opened = await openInstallLog(stageContainer);
/// ```
pub fn open_install_log(stage_container: &Path) -> Result<OpenedLog, WorktreeCopyError> {
let path: PathBuf = stage_container.join(INSTALL_LOG_NAME);
let existed: bool = std::fs::symlink_metadata(path.as_path()).is_ok();
let (recorded, complete_length, total_length) = if existed {
read_complete_records(path.as_path())?
} else {
(InstallLogRecords::default(), 0, 0)
};
let opened: std::io::Result<std::fs::File> = std::fs::OpenOptions::new()
.create(true)
.append(true)
.custom_flags(libc::O_NOFOLLOW)
.mode(0o600)
.open(path.as_path());
let file: std::fs::File = match opened {
Ok(file) => file,
Err(error) => {
let message: String = format!(
"cli-git: could not open worktree-copy install log {}.",
quote(path.to_string_lossy().as_ref())
);
return Err(failure_with_cause(message.as_str(), Some(&error)));
}
};
if complete_length < total_length {
file.set_len(complete_length)
.map_err(|error| return raw_failure(&error))?;
file.sync_all()
.map_err(|error| return raw_failure(&error))?;
}
if !existed {
sync_directory(stage_container).map_err(|error| return raw_failure(&error))?;
}
return Ok(OpenedLog {
log: InstallLog { file, path },
recorded,
});
}
impl InstallLog {
/// Appends one line and waits until it is durable; an empty batch writes nothing.
fn append_line(&mut self, line: &str) -> Result<(), WorktreeCopyError> {
let written: std::io::Result<()> = self
.file
.write_all(line.as_bytes())
.and_then(|()| return self.file.sync_all());
return written.map_err(|error| {
let message: String = format!(
"cli-git: could not persist worktree-copy install log {}.",
quote(self.path.to_string_lossy().as_ref())
);
return failure_with_cause(message.as_str(), Some(&error));
});
}
/// Durably appends the selected paths one batch is about to create.
pub fn append_intents(&mut self, relative_paths: &[String]) -> Result<(), WorktreeCopyError> {
if relative_paths.is_empty() {
return Ok(());
}
let line: String = format!("{{\"intended\":{}}}\n", string_array(relative_paths));
return self.append_line(line.as_str());
}
/// Durably appends the identities of the paths one batch created.
pub fn append_creations(&mut self, entries: &[InstalledPath]) -> Result<(), WorktreeCopyError> {
if entries.is_empty() {
return Ok(());
}
let encoded: Vec<String> = entries.iter().map(encode_installed_entry).collect();
let line: String = format!("{{\"created\":[{}]}}\n", encoded.join(","));
return self.append_line(line.as_str());
}
}
/// Install-log controls stay out of the release executable.
#[cfg(test)]
#[path = "worktree_copy_install_log_tests.rs"]
mod tests;