fix: normalize SSH migration timestamp comparisons
This commit is contained in:
@@ -32,7 +32,8 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
tx.execute_batch(
|
||||
"CREATE TEMP TABLE repository_key_cutover (
|
||||
workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
||||
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
||||
created_at_seconds INTEGER NOT NULL, created_at_subsec_nanos INTEGER NOT NULL,
|
||||
PRIMARY KEY(workspace_id,kind,resource_id,legacy_counter));",
|
||||
)?;
|
||||
for (table, kind, id, fingerprint) in [
|
||||
@@ -68,15 +69,20 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
for (workspace, resource, legacy_counter, fingerprint, created) in rows {
|
||||
let mut statement = tx.prepare("SELECT operation_id FROM repository_secret_operations WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
|
||||
let matches = statement
|
||||
.query_map(params![workspace, kind, resource, legacy_counter, created], |r| {
|
||||
r.get::<_, String>(0)
|
||||
})?
|
||||
.query_map(
|
||||
params![workspace, kind, resource, legacy_counter, created],
|
||||
|r| r.get::<_, String>(0),
|
||||
)?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
if matches.len() != 1 || matches[0].is_empty() {
|
||||
return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into()));
|
||||
}
|
||||
let created_time = frozen_repository_timestamp(
|
||||
&created,
|
||||
&format!("{table}.created_at (workspace {workspace}, resource {resource})"),
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7)",
|
||||
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
params![
|
||||
workspace,
|
||||
kind,
|
||||
@@ -84,11 +90,26 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
legacy_counter,
|
||||
matches[0],
|
||||
fingerprint,
|
||||
created
|
||||
created_time.timestamp(),
|
||||
created_time.timestamp_subsec_nanos()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Candidate copying uses an INNER JOIN: reject corrupt source rows before
|
||||
// they can disappear and evade the destination foreign-key check.
|
||||
let orphaned_candidates: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM workdir_create_credential_candidates c
|
||||
LEFT JOIN workdir_create_operations o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
|
||||
WHERE o.operation_id IS NULL)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if orphaned_candidates {
|
||||
return Err(Error::Store(
|
||||
"Repository key cutover found credential candidates without a Workdir create operation".into(),
|
||||
));
|
||||
}
|
||||
// Current rows must agree with their pointed-to historical key, not merely
|
||||
// resolve to a nonempty receipt. FK checks alone cannot prove this identity.
|
||||
let inconsistent: bool = tx.query_row("SELECT EXISTS (
|
||||
@@ -148,8 +169,17 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (workspace, id, legacy_counter, purpose, algorithm, nonce, ciphertext, created, operation) in
|
||||
secrets
|
||||
for (
|
||||
workspace,
|
||||
id,
|
||||
legacy_counter,
|
||||
purpose,
|
||||
algorithm,
|
||||
nonce,
|
||||
ciphertext,
|
||||
created,
|
||||
operation,
|
||||
) in secrets
|
||||
{
|
||||
if algorithm != "aes-256-gcm-v1" {
|
||||
return Err(Error::Store(
|
||||
@@ -233,8 +263,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o
|
||||
WHERE o.state='succeeded'
|
||||
AND NOT EXISTS(SELECT 1 FROM workdir_create_credential_revision_retentions_v86 r WHERE r.workspace_id=o.workspace_id AND r.operation_id=o.operation_id)
|
||||
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));
|
||||
INSERT INTO repository_secret_legacy_receipts
|
||||
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));")?;
|
||||
prepare_repository_cutover_times(&tx)?;
|
||||
tx.execute_batch("INSERT INTO repository_secret_legacy_receipts
|
||||
SELECT e.workspace_id,e.operation_id,e.request_fingerprint,e.resource_kind,e.resource_id,e.result_revision,e.created_at,e.mutation_kind,
|
||||
CASE WHEN e.mutation_kind IN ('credential_created','host_trust_created') THEN 0
|
||||
WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') AND e.result_revision>1 THEN e.result_revision-1
|
||||
@@ -244,8 +275,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
WHERE a.workspace_id=o.workspace_id AND a.resource_id=o.resource_id AND a.revision=o.result_revision AND a.created_at=o.created_at
|
||||
AND ((o.resource_kind='credential' AND a.kind LIKE 'credential_%') OR (o.resource_kind='host_trust' AND a.kind LIKE 'host_trust_%'))
|
||||
HAVING count(*)=1) AS mutation_kind FROM repository_secret_operations_v86 o) e
|
||||
JOIN repository_cutover_times t ON t.kind='receipt' AND t.workspace_id=e.workspace_id AND t.operation_id=e.operation_id
|
||||
LEFT JOIN repository_key_cutover m ON m.workspace_id=e.workspace_id AND m.kind=e.resource_kind AND m.resource_id=e.resource_id
|
||||
AND m.created_at<=e.created_at
|
||||
AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
AND m.legacy_counter=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
|
||||
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END;
|
||||
INSERT INTO workdir_create_operations
|
||||
@@ -257,12 +289,14 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END
|
||||
FROM workdir_create_operations_v86 o
|
||||
LEFT JOIN workdir_terminal_archive_cutover a ON a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND c.created_at<=o.updated_at
|
||||
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND h.created_at<=o.updated_at;
|
||||
LEFT JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND (c.created_at_seconds,c.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND (h.created_at_seconds,h.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos);
|
||||
INSERT INTO workdir_create_credential_candidates
|
||||
SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint
|
||||
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id LEFT JOIN repository_key_cutover m
|
||||
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND m.created_at<=o.updated_at
|
||||
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
|
||||
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
|
||||
LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a WHERE a.workspace_id=c.workspace_id AND a.operation_id=c.operation_id);
|
||||
INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision;")?;
|
||||
let missing:i64=tx.query_row("SELECT count(*) FROM workdir_create_operations WHERE (credential_id IS NOT NULL AND credential_fingerprint IS NULL) OR (host_trust_id IS NOT NULL AND host_trust_fingerprint IS NULL)",[],|r|r.get(0))?;
|
||||
@@ -313,7 +347,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
||||
] {
|
||||
tx.execute_batch(&format!("DROP TABLE {table}_v86;"))?;
|
||||
}
|
||||
tx.execute_batch("DROP TABLE repository_key_cutover;
|
||||
tx.execute_batch("DROP TABLE repository_key_cutover; DROP TABLE repository_cutover_times;
|
||||
CREATE INDEX idx_repository_secret_audit_workspace_created ON repository_secret_audit_events(workspace_id,created_at,event_id);
|
||||
CREATE INDEX idx_repository_ssh_credentials_workspace_status ON repository_ssh_credentials(workspace_id,status,credential_id);
|
||||
CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id,hostname,port);")?;
|
||||
@@ -360,6 +394,109 @@ BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||
result.and(restore)
|
||||
}
|
||||
|
||||
// Schema 86 used RFC3339 for Repository secrets and epoch-millisecond text for
|
||||
// registry operations. Compare typed instants, never their persisted spelling.
|
||||
fn frozen_repository_timestamp(
|
||||
value: &str,
|
||||
context: &str,
|
||||
) -> Result<chrono::DateTime<chrono::Utc>> {
|
||||
if let Ok(timestamp) = chrono::DateTime::parse_from_rfc3339(value) {
|
||||
return Ok(timestamp.with_timezone(&chrono::Utc));
|
||||
}
|
||||
if let Ok(millis) = value.parse::<i64>()
|
||||
&& let Some(timestamp) = chrono::DateTime::from_timestamp_millis(millis)
|
||||
{
|
||||
return Ok(timestamp);
|
||||
}
|
||||
Err(Error::Store(format!(
|
||||
"Repository key migration found invalid timestamp in {context}"
|
||||
)))
|
||||
}
|
||||
|
||||
fn prepare_repository_cutover_times(tx: &rusqlite::Transaction<'_>) -> Result<()> {
|
||||
// These temporary normalized values never rewrite audit history or the
|
||||
// original timestamps used to identify receipts and encrypted envelopes.
|
||||
// Seconds + nanoseconds also preserve sub-millisecond ordering and offsets.
|
||||
tx.execute_batch(
|
||||
"CREATE TEMP TABLE repository_cutover_times (
|
||||
kind TEXT NOT NULL, workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL,
|
||||
seconds INTEGER NOT NULL, subsec_nanos INTEGER NOT NULL,
|
||||
PRIMARY KEY(kind,workspace_id,operation_id));",
|
||||
)?;
|
||||
for (kind, context, query) in [
|
||||
(
|
||||
"receipt",
|
||||
"repository_secret_operations.created_at",
|
||||
"SELECT workspace_id,operation_id,created_at FROM repository_secret_operations_v86",
|
||||
),
|
||||
(
|
||||
"workdir",
|
||||
"workdir_create_operations.updated_at",
|
||||
"SELECT o.workspace_id,o.operation_id,o.updated_at FROM workdir_create_operations_v86 o
|
||||
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a
|
||||
WHERE a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id)
|
||||
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR
|
||||
EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c
|
||||
WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id))",
|
||||
),
|
||||
] {
|
||||
let rows = tx
|
||||
.prepare(query)?
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
for (workspace, operation, value) in rows {
|
||||
let timestamp = frozen_repository_timestamp(
|
||||
&value,
|
||||
&format!("{context} (workspace {workspace}, operation {operation})"),
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO repository_cutover_times VALUES(?1,?2,?3,?4,?5)",
|
||||
params![
|
||||
kind,
|
||||
workspace,
|
||||
operation,
|
||||
timestamp.timestamp(),
|
||||
timestamp.timestamp_subsec_nanos()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Refuse missing or newer/reused keys with a domain error before a NULL
|
||||
// fingerprint reaches a NOT NULL column. Archived successes are excluded:
|
||||
// they retain opaque history, not authority to retry with a current key.
|
||||
let missing: Option<(String, String)> = tx.query_row(
|
||||
"SELECT r.workspace_id,r.operation_id FROM (
|
||||
SELECT workspace_id,operation_id,'credential' AS kind,credential_id AS resource_id,credential_revision AS legacy_counter
|
||||
FROM workdir_create_operations_v86 WHERE credential_id IS NOT NULL
|
||||
UNION
|
||||
SELECT workspace_id,operation_id,'host_trust',host_trust_id,host_trust_revision
|
||||
FROM workdir_create_operations_v86 WHERE host_trust_id IS NOT NULL
|
||||
UNION
|
||||
SELECT workspace_id,operation_id,'credential',credential_id,credential_revision
|
||||
FROM workdir_create_credential_candidates_v86
|
||||
) r
|
||||
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=r.workspace_id AND t.operation_id=r.operation_id
|
||||
LEFT JOIN repository_key_cutover k ON k.workspace_id=r.workspace_id AND k.kind=r.kind
|
||||
AND k.resource_id=r.resource_id AND k.legacy_counter=r.legacy_counter
|
||||
AND (k.created_at_seconds,k.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
|
||||
WHERE k.operation_id IS NULL LIMIT 1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
).optional()?;
|
||||
if let Some((workspace, operation)) = missing {
|
||||
return Err(Error::Store(format!(
|
||||
"Workdir SSH snapshot is missing key identity evidence at or before its timestamp (workspace {workspace}, operation {operation})"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives
|
||||
// are never read by credential lookup, lease construction, or a live CAS.
|
||||
fn frozen_repository_json_rows(
|
||||
|
||||
@@ -32,12 +32,12 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
|
||||
let end = start + frozen[start..].find(end).unwrap();
|
||||
conn.execute_batch(&frozen[start..end]).unwrap();
|
||||
}
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','created');
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_revision,config_projection_digest,working_directory_id,state,created_at,updated_at,credential_id,credential_revision)
|
||||
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','created','created','key',7);
|
||||
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','key',7);
|
||||
INSERT INTO workdir_create_credential_candidates VALUES('space','create-workdir',0,'primary','key',7);
|
||||
INSERT INTO workdir_create_credential_revision_retentions VALUES('space','create-workdir',0,'key',7);
|
||||
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
|
||||
@@ -59,11 +59,300 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
|
||||
&mut ciphertext,
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'created')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'2026-01-01T00:00:00.000Z')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
// Exercise the actual persisted formats, including failed creates whose SSH
|
||||
// snapshot remains retry authority. The encrypted fixture uses the real old AAD.
|
||||
fn legacy_ssh_snapshot_times(conn: &Connection, key_created: &str, workdir_updated: &str) {
|
||||
conn.execute(
|
||||
"UPDATE repository_secret_operations SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE repository_secret_audit_events SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credentials SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE server_secret_versions SET created_at=?1",
|
||||
[key_created],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO repository_secret_operations VALUES('space','create-host','intent','host_trust','host',3,?1)", [key_created]).unwrap();
|
||||
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_created','host',3,'actor',?1)", [key_created]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','host-key','host-fingerprint',3,?1,?1)", [key_created]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'example.test',22,'ssh-ed25519','host-key','host-fingerprint',?1)", [key_created]).unwrap();
|
||||
conn.execute("UPDATE workdir_create_operations SET state='failed',failure='runtime_workdir_create_failed',host_trust_id='host',host_trust_revision=3,repository_access_mode='read_only',updated_at=?1", [workdir_updated]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_compares_mixed_timestamp_formats_as_instants() {
|
||||
for (key_created, workdir_updated) in [
|
||||
("2026-01-01T00:00:00.000Z", "1767225600000"),
|
||||
("2026-01-01T00:00:00.000Z", "1767225600001"),
|
||||
("2026-01-01T09:00:00.000+09:00", "2026-01-01T00:00:00Z"),
|
||||
(
|
||||
"2026-01-01T00:00:00.000000001Z",
|
||||
"2026-01-01T00:00:00.000000002Z",
|
||||
),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
legacy_ssh_snapshot_times(&conn, key_created, workdir_updated);
|
||||
migrate_repository_keys_v86_to_v87(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 87);
|
||||
let snapshot: (String, String, String, String) = conn.query_row(
|
||||
"SELECT state,credential_fingerprint,host_trust_fingerprint,updated_at FROM workdir_create_operations",
|
||||
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
snapshot,
|
||||
(
|
||||
"failed".into(),
|
||||
"fingerprint".into(),
|
||||
"host-fingerprint".into(),
|
||||
workdir_updated.into()
|
||||
)
|
||||
);
|
||||
let retained: (String, String, String) = conn.query_row(
|
||||
"SELECT c.credential_fingerprint,r.credential_fingerprint,r.credential_operation_id
|
||||
FROM workdir_create_credential_candidates c JOIN workdir_create_credential_retentions r
|
||||
ON r.workspace_id=c.workspace_id AND r.operation_id=c.operation_id AND r.ordinal=c.ordinal",
|
||||
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
retained,
|
||||
(
|
||||
"fingerprint".into(),
|
||||
"fingerprint".into(),
|
||||
"create-key".into()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT created_at FROM repository_ssh_credential_keys",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
key_created
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
.unwrap()
|
||||
.exists([])
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_timestamp_cutover_rolls_back(conn: &Connection, expected_context: &str) {
|
||||
let before =
|
||||
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap();
|
||||
let ciphertext: Vec<u8> = conn
|
||||
.query_row(
|
||||
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let error = migrate_repository_keys_v86_to_v87(conn)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(expected_context), "{error}");
|
||||
assert!(error.contains("timestamp"), "{error}");
|
||||
assert_eq!(current_schema_version(conn).unwrap(), 86);
|
||||
assert_eq!(
|
||||
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
|
||||
[],
|
||||
|r| r.get::<_, Vec<u8>>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
ciphertext
|
||||
);
|
||||
assert!(table_exists(conn, "repository_ssh_credential_revisions").unwrap());
|
||||
assert!(!table_exists(conn, "repository_ssh_credential_keys").unwrap());
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM workdir_create_credential_candidates",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM workdir_create_credential_revision_retentions",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_temp_master WHERE name IN ('repository_key_cutover','repository_cutover_times','workdir_terminal_archive_cutover')", [], |r| r.get::<_,i64>(0)).unwrap(), 0);
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_rejects_newer_reused_snapshot_keys_without_committing_authority() {
|
||||
for source in ["credential", "host_trust", "candidate"] {
|
||||
for later in ["2026-01-01T00:00:00.001Z", "2026-01-01T00:00:00.000000001Z"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
|
||||
if source == "host_trust" {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
|
||||
[later],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [later]).unwrap();
|
||||
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='host_trust_created'", [later]).unwrap();
|
||||
} else {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
|
||||
[later],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [later]).unwrap();
|
||||
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='credential_created'", [later]).unwrap();
|
||||
if source == "candidate" {
|
||||
conn.execute("UPDATE workdir_create_operations SET credential_id=NULL,credential_revision=NULL", []).unwrap();
|
||||
}
|
||||
}
|
||||
assert_timestamp_cutover_rolls_back(&conn, "Workdir SSH snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_invalid_authority_timestamps_roll_back_with_source_context() {
|
||||
for (source, invalid) in [
|
||||
("workdir_create_operations.updated_at", "not-a-date"),
|
||||
(
|
||||
"workdir_create_operations.updated_at",
|
||||
"9223372036854775807",
|
||||
),
|
||||
(
|
||||
"repository_ssh_credential_revisions.created_at",
|
||||
"not-a-date",
|
||||
),
|
||||
(
|
||||
"repository_ssh_host_trust_revisions.created_at",
|
||||
"not-a-date",
|
||||
),
|
||||
("repository_secret_operations.created_at", "not-a-date"),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
|
||||
match source {
|
||||
"workdir_create_operations.updated_at" => {
|
||||
conn.execute(
|
||||
"UPDATE workdir_create_operations SET updated_at=?1",
|
||||
[invalid],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"repository_ssh_credential_revisions.created_at" => {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
|
||||
[invalid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [invalid]).unwrap();
|
||||
}
|
||||
"repository_ssh_host_trust_revisions.created_at" => {
|
||||
conn.execute(
|
||||
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
|
||||
[invalid],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [invalid]).unwrap();
|
||||
}
|
||||
"repository_secret_operations.created_at" => {
|
||||
conn.execute("INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','gone',1,?1)", [invalid]).unwrap();
|
||||
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','gone',1,'actor',?1)", [invalid]).unwrap();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_timestamp_cutover_rolls_back(&conn, source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_rejects_orphan_candidates_without_dropping_source_evidence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF;
|
||||
INSERT INTO workdir_create_credential_candidates VALUES('space','missing-parent',0,'primary','key',7);
|
||||
PRAGMA foreign_keys=ON;").unwrap();
|
||||
let before = frozen_repository_json_rows(
|
||||
&conn,
|
||||
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let error = migrate_repository_keys_v86_to_v87(&conn).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("credential candidates without a Workdir create operation"),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 86);
|
||||
assert_eq!(
|
||||
frozen_repository_json_rows(
|
||||
&conn,
|
||||
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
|
||||
&[]
|
||||
)
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(table_exists(&conn, "repository_ssh_credential_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "repository_ssh_credential_keys").unwrap());
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -150,12 +439,12 @@ fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
|
||||
.unwrap();
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE workdir_create_operations SET updated_at='later'",
|
||||
"UPDATE workdir_create_operations SET updated_at='2026-01-04T00:00:00.000Z'",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','created','created')", []).is_err());
|
||||
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')", []).is_err());
|
||||
assert!(
|
||||
!conn
|
||||
.prepare("PRAGMA foreign_key_check")
|
||||
@@ -274,14 +563,14 @@ fn repository_key_cutover_preserves_host_endpoints_deleted_audit_and_nondefault_
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'created');
|
||||
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'updated');
|
||||
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'created','updated');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','created');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','updated');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','updated');
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'deleted');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','deleted');
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','2026-01-02T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'2026-01-03T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','2026-01-03T00:00:00.000Z');
|
||||
UPDATE workdir_create_operations SET host_trust_id='host',host_trust_revision=3;").unwrap();
|
||||
conn.execute_batch(&format!(
|
||||
"PRAGMA foreign_keys={foreign_keys}; PRAGMA legacy_alter_table={legacy_alter};"
|
||||
@@ -342,8 +631,8 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr
|
||||
let digest = "sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b";
|
||||
conn.execute("UPDATE workdir_create_operations SET state='succeeded',request_fingerprint=?1,selector='main',source_kind='local_path',source_uri='/tmp/repo',source_revision=9,source_fingerprint='source'",[digest]).unwrap();
|
||||
conn.execute_batch("DELETE FROM workdir_create_credential_revision_retentions;
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'deleted');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','deleted');
|
||||
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'2026-01-03T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','2026-01-03T00:00:00.000Z');
|
||||
DELETE FROM repository_ssh_credentials;").unwrap();
|
||||
let original =
|
||||
frozen_repository_json_rows(&conn, "SELECT * FROM workdir_create_operations", &[])
|
||||
@@ -666,7 +955,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"credential",
|
||||
"key",
|
||||
1,
|
||||
"2026-01-01",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
"credential_created",
|
||||
),
|
||||
(
|
||||
@@ -675,7 +964,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"credential",
|
||||
"key",
|
||||
2,
|
||||
"2026-01-02",
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
"credential_rotated",
|
||||
),
|
||||
(
|
||||
@@ -684,7 +973,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"host_trust",
|
||||
"host",
|
||||
1,
|
||||
"2026-01-01",
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
"host_trust_created",
|
||||
),
|
||||
(
|
||||
@@ -693,7 +982,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
"host_trust",
|
||||
"host",
|
||||
2,
|
||||
"2026-01-02",
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
"host_trust_rotated",
|
||||
),
|
||||
] {
|
||||
@@ -708,11 +997,11 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01','2026-01-02')",[&next_fp]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",[&next_fp]).unwrap();
|
||||
let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
|
||||
for (legacy_counter, fingerprint, private, timestamp) in [
|
||||
(1u64, &old_fp, &old_private, "2026-01-01"),
|
||||
(2, &next_fp, &next_private, "2026-01-02"),
|
||||
(1u64, &old_fp, &old_private, "2026-01-01T00:00:00.000Z"),
|
||||
(2, &next_fp, &next_private, "2026-01-02T00:00:00.000Z"),
|
||||
] {
|
||||
conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![legacy_counter,fingerprint,timestamp]).unwrap();
|
||||
let nonce = [legacy_counter as u8; 12];
|
||||
@@ -727,10 +1016,10 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![legacy_counter,nonce.as_slice(),ciphertext,timestamp]).unwrap();
|
||||
}
|
||||
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01','2026-01-02')",params![public,old_fp]).unwrap();
|
||||
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",params![public,old_fp]).unwrap();
|
||||
for (legacy_counter, hostname, timestamp) in [
|
||||
(1, "old.example.test", "2026-01-01"),
|
||||
(2, "new.example.test", "2026-01-02"),
|
||||
(1, "old.example.test", "2026-01-01T00:00:00.000Z"),
|
||||
(2, "new.example.test", "2026-01-02T00:00:00.000Z"),
|
||||
] {
|
||||
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![legacy_counter,hostname,public,old_fp,timestamp]).unwrap();
|
||||
}
|
||||
@@ -884,8 +1173,8 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() {
|
||||
// The old deleted ID has been recreated using the same ordinal. Its earlier
|
||||
// delete receipt must remain history, not acquire proof from this new key.
|
||||
conn.execute_batch("DELETE FROM workdir_create_operations;
|
||||
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01');").unwrap();
|
||||
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01T00:00:00.000Z');").unwrap();
|
||||
migrate_repository_keys_v86_to_v87(&conn).unwrap();
|
||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||
@@ -966,8 +1255,8 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
|
||||
}
|
||||
"inconsistent_retention" => {
|
||||
// A different valid retained key passes the ordinal-only candidate FK.
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'later');
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','later');
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'2026-01-04T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','2026-01-04T00:00:00.000Z');
|
||||
UPDATE workdir_create_credential_revision_retentions SET credential_revision=8;").unwrap();
|
||||
}
|
||||
"missing_audit_receipt" => {
|
||||
@@ -978,12 +1267,12 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
|
||||
.unwrap();
|
||||
}
|
||||
"missing_host_receipt" => {
|
||||
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'created','created');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','created');").unwrap();
|
||||
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','2026-01-01T00:00:00.000Z');").unwrap();
|
||||
}
|
||||
"foreign_key_violation" => {
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF;
|
||||
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'created');
|
||||
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'2026-01-01T00:00:00.000Z');
|
||||
PRAGMA foreign_keys=ON;").unwrap();
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -80,10 +80,10 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
seed_migration_workspace(&conn);
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','created');").unwrap();
|
||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
|
||||
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
|
||||
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','2026-01-01T00:00:00.000Z');").unwrap();
|
||||
let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
|
||||
let mut ciphertext = b"private bytes".to_vec();
|
||||
key.seal_in_place_append_tag(
|
||||
@@ -92,7 +92,7 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
|
||||
&mut ciphertext,
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'created')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
|
||||
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'2026-01-01T00:00:00.000Z')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
|
||||
drop(conn);
|
||||
let master_key = dir.path().join("repository-secrets.master-key");
|
||||
std::fs::write(&master_key, [42; 32]).unwrap();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# schema86→87のSSH移行: 異なる時刻形式の文字列比較でfingerprintがNULLになる
|
||||
|
||||
調査日: 2026-10-10
|
||||
報告された起動エラー:
|
||||
|
||||
`yoi-server: store error: workspace schema migration failed: sqlite error: NOT NULL constraint failed: workdir_create_credential_candidates.credential_fingerprint`
|
||||
|
||||
## 原因
|
||||
|
||||
`crates/workspace-server/src/frozen_repository_key_migration.rs:262–266`のcredential candidate移行は、旧credential ID/revisionから`repository_key_cutover`へLEFT JOINし、`m.fingerprint`を新しいNOT NULL列に挿入する。
|
||||
|
||||
JOIN条件の`m.created_at <= o.updated_at`が、RFC3339の鍵作成時刻とepoch-millis文字列のWorkdir更新時刻を、そのままTEXTとして比較している。日時としての順序を判定していないため、有効な古い鍵のJOINが外れ、fingerprintがNULLになる。
|
||||
|
||||
- 鍵作成時刻: `repository_access.rs:2256`の`Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)`。
|
||||
- Workdir作成・更新時刻: `server.rs:30936–30940`の`now_registry_timestamp()`はepochミリ秒の文字列。reserveでもこれを使用する(`server.rs:24272–24308`)。
|
||||
|
||||
同じ異形式比較はoperation本体のcredential/host trust移行にもある(migration L260–261)。candidateだけ直してもoperation snapshotのfingerprintが欠落する問題が残る。
|
||||
|
||||
## 実データによる確認
|
||||
|
||||
ユーザーから共有済みの`yoi.local/server/server.db`をNodeの`DatabaseSync({readOnly: true})`と`PRAGMA query_only=ON`で照合した。秘密鍵、暗号化envelope、master keyは取得していない。migrationやDB更新も実行していない。
|
||||
|
||||
- DBはschema86のまま。
|
||||
- 新candidateへ移される5件すべてがfailed Workdir createのcredential候補で、旧retentionが各1件あるため成功済み・unretainedのarchive経路には入らない。
|
||||
- 5件とも参照先の鍵は存在する。対応するmutation receiptも各1件あり、証拠欠落ではない。
|
||||
- 5件ともSQLiteのTEXT比較はfalse、実際の時刻比較はtrue。
|
||||
|
||||
代表例:
|
||||
|
||||
| 項目 | 値 |
|
||||
| --- | --- |
|
||||
| operation | `call_mgmyCNofdQMZ8uyI5tg0mBhv` |
|
||||
| credential | `workspace-default`、旧revision 1 |
|
||||
| 鍵created_at | `2026-09-16T18:44:56.736Z` |
|
||||
| Workdir updated_at | `1790173821030` |
|
||||
| Workdir updated_atを日時に変換 | `2026-09-23T14:30:21.030Z` |
|
||||
| SQLiteの文字列比較 | false |
|
||||
| 日時としての比較 | true |
|
||||
|
||||
他の4件では鍵作成が`2026-09-10T13:09:52.113Z`、Workdir更新は9/23または9/29で、同じ問題になる。
|
||||
|
||||
## 修正方針(この調査では未実装)
|
||||
|
||||
- frozen migration内で旧データの時刻形式を明示的に読み、同じ時刻型/epoch-millisへ正規化して比較する。文字列比較やSQLの安易なCASTに頼らない。
|
||||
- `created_at <= updated_at`の安全確認そのものは削除しない。旧ID/revisionが削除後に再利用された鍵を、過去のcandidateに誤って結び付けることを防ぐ条件である。
|
||||
- candidateだけでなくoperation本体のcredential/host trust等、同じ境界の比較を確認する。
|
||||
- 正規化不能・不正な時刻や真に必要な証拠が欠落した場合は、NOT NULLエラーになる前に文脈付きで拒否し、transactionをrollbackする。
|
||||
|
||||
既存の`frozen_repository_key_tests.rs:35–42`の共通fixtureは、鍵とWorkdirの時刻をすべて`created`に揃えており、実データの異形式を再現していない。RFC3339鍵時刻+epoch-millis Workdir時刻での移行成功と、実時刻が操作より後の再作成鍵を拒否するケースが必要。テスト追加時は`docs/development/rust-testing-strategy.md`に従う。
|
||||
|
||||
## 影響・実施範囲
|
||||
|
||||
schema86→87はtransaction内で実行され、version87の記録とcommitは最後に行う(migration L29、L349–350)。今回のDBでもschema86と旧テーブルを確認した。ただし、それより前のschema更新まで含めた全起動処理が一括で元に戻るという意味ではない。
|
||||
|
||||
NOT NULLを外す、適当なfingerprintを入れる、candidate/retentionを消して通す、といったDB修復は行うべきではない。正しい既存データをmigrationが誤判定しているので、移行コード側を修正する。
|
||||
|
||||
調査時点で追加したのは本報告のみ。以下は、その後のユーザー指示による修正記録。
|
||||
|
||||
## 修正と検証(2026-10-10)
|
||||
|
||||
- RFC3339/epoch-millisをRustで読み、temporary tableのUTC秒・ナノ秒へ正規化して比較するよう修正。credential/host trustのoperation snapshot、candidate、旧receiptの時刻条件に適用した。
|
||||
- タイムゾーン・sub-millisecondの順序を維持し、元の履歴時刻やreceipt照合用の文字列、暗号化AADは書き換えない。永続schemaの変更・version bumpは不要。
|
||||
- 不正な時刻、欠けた鍵、操作より新しい再利用鍵は文脈付きで拒否しrollback。NOT NULLを緩めたり候補を削除したりしない。
|
||||
- 独立コード確認で見つかった、親operationのない旧candidateがINNER JOINから落ちて消える経路も、コピー前の拒否とrollbackテストで保護した。追修正に追加のblocking findingなし。
|
||||
- 偽時刻`created`や日付だけのfixtureは実際のRFC3339へ修正。新規回帰は混在形式・同時刻・offset・ナノ秒精度、各SSH参照での新しい鍵拒否、不正時刻、孤立候補の保持を検証する。
|
||||
- 最小の混在形式テスト、migrationモジュール、公開migration dry-run/applyテスト、孤立候補テストを順に実行して成功。最終の`cargo test -p yoi-workspace-server`はlibrary **815 passed / 1 ignored**、binary **11 passed**、doctest失敗なし。
|
||||
- root `cargo check`、`cargo fmt --all -- --check`、変更したincludeファイルの明示的`rustfmt --check`、`git diff --check HEAD`も成功。
|
||||
- 稼働DBでのmigration、ビルド済みバイナリの配備、サービスの停止・再起動は行っていない。更新はユーザー操作のまま。
|
||||
|
||||
作業中、テスト担当SubWorkerはcwd基準の読取で、scopeに付与した別ディレクトリのテスト規則を参照できなかった(絶対pathもinvalid logical filesystem pathで拒否)。編集なしを確認して停止し、親でテストを実装した。読取専用レビュー担当はroot cwdで規則・ソースを読めた。cwdとscopeにまたがる論理pathの案内・検証がツール上の改善点。
|
||||
Reference in New Issue
Block a user