Skip to content

Commit ad4620b

Browse files
committed
fix(core): remove the added cameras' staging files with the last profile
A write of the added cameras' store, or of its commit journal, first puts the new bytes in a staging file beside the target and then renames it over the target. If the process crashes, or the write or sync fails, before the rename, the staging file stays behind. On a host without a TPM it holds plaintext embeddings. Deleting the last profile removed the store and the journal but not these files. - storage::delete also removes the staging files that the store's and the journal's writers leave (.<user>.json.tmp-<pid>, .<user>.json.commit-tmp-<pid>, .<user>.json.intent.commit-tmp-<pid>). It removes them after the journal and the store and before the enrollment, syncing the directory after each removal, so a failure still leaves the request repeatable. Deleted.camera_store counts them. - The writers take these names from multi_camera::staging_path, and the sweep matches them with is_staging_file_of. A match needs the account's exact file name followed by one staging tag and a decimal pid, so another account's files never match. Tests: deleting_an_account_removes_the_staging_files_its_camera_store_writers_left, which fails without the sweep, and staging_files_of_the_store_and_its_journal_match_and_other_names_do_not. Signed-off-by: Wisbendji Fimerlus <archledger236@gmail.com>
1 parent d2fd1aa commit ad4620b

3 files changed

Lines changed: 180 additions & 23 deletions

File tree

‎crates/irlume-core/src/multi_camera.rs‎

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,55 @@ pub fn secondary_store_path(user: &str) -> PathBuf {
116116
.join(format!("{user}.json"))
117117
}
118118

119+
/// The staging tag of [`save_secondary`]'s writes.
120+
const SAVE_STAGING_TAG: &str = "tmp";
121+
122+
/// The staging tag of the commit protocol's writes, of the store and of its
123+
/// journal ([`commit::publish_with_intent`]).
124+
const COMMIT_STAGING_TAG: &str = "commit-tmp";
125+
126+
/// The file a writer stages `path`'s new bytes in before renaming it over
127+
/// `path`: `.<file name>.<tag>-<pid>` in the same directory. A crash, or a
128+
/// failed write or sync, before the rename leaves it behind with those
129+
/// bytes, which are plaintext embeddings on a host without a TPM.
130+
fn staging_path(path: &Path, tag: &str) -> PathBuf {
131+
let name = path
132+
.file_name()
133+
.map_or_else(|| "secondary".into(), |n| n.to_string_lossy().into_owned());
134+
path.parent()
135+
.unwrap_or_else(|| Path::new("."))
136+
.join(format!(".{name}.{tag}-{}", std::process::id()))
137+
}
138+
139+
/// Whether `name`, an entry of the directory of the store at `store`, is a
140+
/// staging file a writer of that store or of its commit journal left behind
141+
/// ([`staging_path`], any process id). Another account's files never match:
142+
/// the file name must be followed by exactly one staging tag and a decimal
143+
/// process id.
144+
pub(crate) fn is_staging_file_of(store: &Path, name: &std::ffi::OsStr) -> bool {
145+
let Some(name) = name.to_str() else {
146+
return false;
147+
};
148+
let journal = commit::intent_path_for(store);
149+
let staged_by = |file: &str, tag: &str| {
150+
name.strip_prefix('.')
151+
.and_then(|rest| rest.strip_prefix(file))
152+
.and_then(|rest| rest.strip_prefix('.'))
153+
.and_then(|rest| rest.strip_prefix(tag))
154+
.and_then(|rest| rest.strip_prefix('-'))
155+
.is_some_and(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit()))
156+
};
157+
let (Some(store), Some(journal)) = (
158+
store.file_name().and_then(std::ffi::OsStr::to_str),
159+
journal.file_name().and_then(std::ffi::OsStr::to_str),
160+
) else {
161+
return false;
162+
};
163+
staged_by(store, SAVE_STAGING_TAG)
164+
|| staged_by(store, COMMIT_STAGING_TAG)
165+
|| staged_by(journal, COMMIT_STAGING_TAG)
166+
}
167+
119168
/// The primary enrollment's on-disk path for `user` - the exact file whose
120169
/// bytes the secondary store's activation digest is taken over. Delegates
121170
/// to the loader's own resolution so the two can never drift apart.
@@ -835,13 +884,7 @@ pub(crate) fn save_secondary_with_key(
835884
// Writers create the store's directory before publication (the fixed
836885
// location sits in a `cameras/` subdirectory legacy code never made).
837886
std::fs::create_dir_all(dir).map_err(|error| SecondaryStoreError::Io(error.to_string()))?;
838-
let temp = dir.join(format!(
839-
".{}.tmp-{}",
840-
path.file_name()
841-
.map(|n| n.to_string_lossy().into_owned())
842-
.unwrap_or_else(|| "secondary".into()),
843-
std::process::id()
844-
));
887+
let temp = staging_path(path, SAVE_STAGING_TAG);
845888
use std::io::Write;
846889
let write_all = |temp: &Path| -> std::io::Result<()> {
847890
// Owner-only regardless of umask (ADR-0024 s1.2 permission clause).
@@ -1230,6 +1273,46 @@ mod tests {
12301273
let _ = std::fs::remove_dir_all(&dir);
12311274
}
12321275

1276+
#[test]
1277+
fn staging_files_of_the_store_and_its_journal_match_and_other_names_do_not() {
1278+
let store = Path::new("/state/cameras/alice.json");
1279+
let journal = commit::intent_path_for(store);
1280+
// Exactly the names the writers stage in, so the deletion sweep and
1281+
// the writers cannot drift apart.
1282+
for staged in [
1283+
staging_path(store, SAVE_STAGING_TAG),
1284+
staging_path(store, COMMIT_STAGING_TAG),
1285+
staging_path(&journal, COMMIT_STAGING_TAG),
1286+
] {
1287+
assert_eq!(staged.parent(), store.parent());
1288+
let name = staged.file_name().expect("a staging file name");
1289+
assert!(is_staging_file_of(store, name), "{}", staged.display());
1290+
assert!(
1291+
!is_staging_file_of(Path::new("/state/cameras/bob.json"), name),
1292+
"another account's deletion took {}",
1293+
staged.display()
1294+
);
1295+
}
1296+
for name in [
1297+
"alice.json",
1298+
"alice.json.intent",
1299+
".alice.json.tmp-",
1300+
".alice.json.tmp-12a",
1301+
".alice.json.tmp-1.swp",
1302+
".alice.json.intent.tmp-x",
1303+
"alice.json.tmp-1",
1304+
".alice.json.bak",
1305+
".alice.json.json.tmp-1",
1306+
".alicee.json.tmp-1",
1307+
".bob.json.commit-tmp-1",
1308+
] {
1309+
assert!(
1310+
!is_staging_file_of(store, std::ffi::OsStr::new(name)),
1311+
"{name} is not a staging file of alice's store"
1312+
);
1313+
}
1314+
}
1315+
12331316
#[test]
12341317
fn profile_group_calibrations_round_trip_and_phase1_fixtures_still_load() {
12351318
let calib = crate::calib::IrCalibration {

‎crates/irlume-core/src/multi_camera/commit.rs‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,7 @@ fn fsync_dir(dir: &Path) -> Result<(), CommitError> {
118118
fn durable_write(path: &Path, bytes: &[u8]) -> Result<(), CommitError> {
119119
use std::io::Write;
120120
let dir = path.parent().unwrap_or_else(|| Path::new("."));
121-
let temp = dir.join(format!(
122-
".{}.commit-tmp-{}",
123-
path.file_name()
124-
.map(|n| n.to_string_lossy().into_owned())
125-
.unwrap_or_else(|| "store".into()),
126-
std::process::id()
127-
));
121+
let temp = super::staging_path(path, super::COMMIT_STAGING_TAG);
128122
let mut file = std::fs::OpenOptions::new()
129123
.write(true)
130124
.create_new(true)

‎crates/irlume-core/src/storage.rs‎

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -939,13 +939,14 @@ pub fn store_is_encrypted(user: &str) -> irlume_common::Result<Option<bool>> {
939939
pub struct Deleted {
940940
/// The primary enrollment file existed and was removed.
941941
pub enrollment: bool,
942-
/// The added cameras' store (`cameras/<user>.json`) or its commit
943-
/// journal existed and was removed.
942+
/// The added cameras' store (`cameras/<user>.json`), its commit journal
943+
/// or a staging file one of their writers left existed and was removed.
944944
pub camera_store: bool,
945945
}
946946

947947
/// Deletes all of `user`'s face data under the user state lock: the added
948-
/// cameras' store and its commit journal (removing the account's face data
948+
/// cameras' store, its commit journal and any staging file an interrupted
949+
/// write of either left beside them (removing the account's face data
949950
/// covers every camera group, ADR-0024 §4.2), then the primary enrollment,
950951
/// then the now-orphaned template key and recovery envelope (a fresh
951952
/// enrollment mints a new key).
@@ -975,15 +976,21 @@ pub fn delete(user: &str) -> irlume_common::Result<Deleted> {
975976
})
976977
}
977978

978-
/// Removes `user`'s added-camera commit journal, then the store, syncing the
979+
/// Removes `user`'s added-camera commit journal, then the store, then the
980+
/// staging files their writers left ([`camera_staging_files`]), syncing the
979981
/// directory after each removal so the journal cannot outlive the store
980-
/// after a crash. The caller holds the user state lock. `Ok(true)` when
981-
/// either file existed.
982+
/// after a crash. The caller holds the user state lock. `Ok(true)` when any
983+
/// of these files existed.
982984
fn delete_camera_store_unlocked(user: &str) -> irlume_common::Result<bool> {
983985
let store = crate::multi_camera::secondary_store_path(user);
984-
let journal = crate::multi_camera::commit::intent_path_for(&store);
986+
let dir = store.parent().unwrap_or_else(|| Path::new("."));
987+
let mut paths = vec![
988+
crate::multi_camera::commit::intent_path_for(&store),
989+
store.clone(),
990+
];
991+
paths.extend(camera_staging_files(&store, dir)?);
985992
let mut removed = false;
986-
for path in [&journal, &store] {
993+
for path in &paths {
987994
match fs::remove_file(path) {
988995
Ok(()) => removed = true,
989996
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
@@ -994,14 +1001,37 @@ fn delete_camera_store_unlocked(user: &str) -> irlume_common::Result<bool> {
9941001
)))
9951002
}
9961003
}
997-
let dir = path.parent().unwrap_or_else(|| Path::new("."));
9981004
fs::File::open(dir)
9991005
.and_then(|dir| dir.sync_all())
10001006
.map_err(|e| irlume_common::Error::Io(format!("sync {}: {e}", dir.display())))?;
10011007
}
10021008
Ok(removed)
10031009
}
10041010

1011+
/// The staging files in `dir` that a writer of the added cameras' store at
1012+
/// `store`, or of its commit journal, left before its rename: an interrupted
1013+
/// or failed write keeps the new store bytes there
1014+
/// ([`crate::multi_camera::is_staging_file_of`]). A missing directory has
1015+
/// none.
1016+
fn camera_staging_files(store: &Path, dir: &Path) -> irlume_common::Result<Vec<PathBuf>> {
1017+
let list_error =
1018+
|e: std::io::Error| irlume_common::Error::Io(format!("list {}: {e}", dir.display()));
1019+
let entries = match fs::read_dir(dir) {
1020+
Ok(entries) => entries,
1021+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1022+
Err(e) => return Err(list_error(e)),
1023+
};
1024+
let mut found = Vec::new();
1025+
for entry in entries {
1026+
let entry = entry.map_err(list_error)?;
1027+
if crate::multi_camera::is_staging_file_of(store, &entry.file_name()) {
1028+
found.push(entry.path());
1029+
}
1030+
}
1031+
found.sort();
1032+
Ok(found)
1033+
}
1034+
10051035
/// Has the startup IR compatibility sweep already run for this space?
10061036
/// The historical name and marker format are retained for compatibility;
10071037
/// the daemon no longer retags enrollment data.
@@ -1211,6 +1241,56 @@ mod tests {
12111241
let _ = fs::remove_dir_all(&dir);
12121242
}
12131243

1244+
#[test]
1245+
fn deleting_an_account_removes_the_staging_files_its_camera_store_writers_left() {
1246+
let _env = crate::testenv::ENV_LOCK
1247+
.lock()
1248+
.unwrap_or_else(|e| e.into_inner());
1249+
let dir = PathBuf::from(crate::test_tmp_dir("delete-camera-staging"));
1250+
let (primary, store, journal, other) = plant_account_state(&dir);
1251+
fs::remove_file(&store).unwrap();
1252+
fs::remove_file(&journal).unwrap();
1253+
let cameras = store.parent().unwrap();
1254+
// What an interrupted save_secondary, and an interrupted commit of
1255+
// the store or of its journal, leave: the store's bytes under a
1256+
// staging name.
1257+
let left = [
1258+
".u.json.tmp-4242",
1259+
".u.json.commit-tmp-17",
1260+
".u.json.intent.commit-tmp-17",
1261+
];
1262+
// Another account's staging files ("v", and "u.json" whose store is
1263+
// u.json.json), and names that only resemble u's.
1264+
let kept = [
1265+
".v.json.tmp-4242",
1266+
".u.json.json.tmp-17",
1267+
".u.json.tmp-",
1268+
".u.json.tmp-17.swp",
1269+
"u.json.tmp-17",
1270+
".u.json.bak",
1271+
];
1272+
for name in left.iter().chain(&kept) {
1273+
fs::write(cameras.join(name), b"{}").unwrap();
1274+
}
1275+
1276+
assert_eq!(
1277+
delete("u").unwrap(),
1278+
Deleted {
1279+
enrollment: true,
1280+
camera_store: true
1281+
}
1282+
);
1283+
for name in left {
1284+
assert!(!cameras.join(name).exists(), "{name} outlived the deletion");
1285+
}
1286+
for name in kept {
1287+
assert!(cameras.join(name).exists(), "{name} is not u's and stays");
1288+
}
1289+
assert!(!primary.exists() && other.exists());
1290+
std::env::remove_var("IRLUME_STATE_DIR");
1291+
let _ = fs::remove_dir_all(&dir);
1292+
}
1293+
12141294
#[test]
12151295
fn read_only_plaintext_load_preserves_enrollment_and_missing_state() {
12161296
let _env = crate::testenv::ENV_LOCK

0 commit comments

Comments
 (0)