Add safe file-root apply
This commit is contained in:
parent
bab13cc0a1
commit
5c91635ea2
8 changed files with 394 additions and 14 deletions
|
|
@ -3,8 +3,8 @@ pub mod service;
|
|||
use base64::Engine;
|
||||
use geth_auth::{AuthExplanation, AuthOp, AuthOpKind};
|
||||
use geth_cas::{
|
||||
BlobInfoSummary, FileConflict, FileConflictKind, FileConflictResolution, FileConflictStatus,
|
||||
FileRoot, FileRootScan, LocalCas, hash_path,
|
||||
BlobInfoSummary, CasTreeEntryKind, CasTreeObject, FileConflict, FileConflictKind,
|
||||
FileConflictResolution, FileConflictStatus, FileRoot, FileRootScan, LocalCas, hash_path,
|
||||
};
|
||||
use geth_config::{GethConfig, GethPaths, RelayMode};
|
||||
use geth_control::{
|
||||
|
|
@ -43,7 +43,7 @@ use geth_types::{
|
|||
ResourceName, SshCertId, SshCertRequestId, UnixMillis,
|
||||
};
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
use std::path::Path;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
|
@ -3397,6 +3397,11 @@ pub fn handle_request(
|
|||
},
|
||||
})
|
||||
}
|
||||
ControlRequest::CasRootApply {
|
||||
source,
|
||||
target,
|
||||
dry_run,
|
||||
} => apply_file_root_tree(&store, node, &source, target, dry_run),
|
||||
ControlRequest::CasConflictRecord {
|
||||
root,
|
||||
path,
|
||||
|
|
@ -4628,6 +4633,203 @@ fn file_conflict_from_stored(stored: StoredFileConflict) -> Result<FileConflict,
|
|||
})
|
||||
}
|
||||
|
||||
fn apply_file_root_tree(
|
||||
store: &Store,
|
||||
node: &LocalNode,
|
||||
source: &str,
|
||||
target: PathBuf,
|
||||
dry_run: bool,
|
||||
) -> Result<ControlResponse, NodeError> {
|
||||
geth_cas::validate_file_root_name(source)?;
|
||||
let source_root = store
|
||||
.get_file_root_by_name(source)?
|
||||
.ok_or_else(|| NodeError::ResourceNotFound(format!("file-root:{source}")))?;
|
||||
let tree_hash = source_root
|
||||
.latest_tree_hash
|
||||
.as_ref()
|
||||
.ok_or_else(|| NodeError::ResourceNotFound(format!("file-root-tree:{source}")))?;
|
||||
let tree_hash = BlobHash::new(tree_hash.clone());
|
||||
let cas = LocalCas::new(node.paths.cas_dir());
|
||||
let tree_bytes = cas.read_bytes(&tree_hash)?;
|
||||
let tree: CasTreeObject = geth_codec::decode_canonical(&tree_bytes)?;
|
||||
|
||||
if target.exists() && !target.is_dir() {
|
||||
return Err(NodeError::Cas(geth_cas::CasError::TreeRootNotDirectory(
|
||||
target.display().to_string(),
|
||||
)));
|
||||
}
|
||||
if !dry_run {
|
||||
std::fs::create_dir_all(&target)?;
|
||||
}
|
||||
|
||||
let mut files_written = 0;
|
||||
let mut dirs_created = 0;
|
||||
let mut conflicts = Vec::new();
|
||||
for entry in &tree.entries {
|
||||
let out = safe_tree_output_path(&target, &entry.path)?;
|
||||
match entry.kind {
|
||||
CasTreeEntryKind::Directory => {
|
||||
if out.exists() {
|
||||
if !out.is_dir() {
|
||||
conflicts.push(record_apply_conflict(
|
||||
store,
|
||||
&source_root,
|
||||
&entry.path,
|
||||
&tree_hash,
|
||||
"remote directory conflicts with a local non-directory path",
|
||||
dry_run,
|
||||
)?);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
dirs_created += 1;
|
||||
if !dry_run {
|
||||
std::fs::create_dir_all(&out)?;
|
||||
}
|
||||
}
|
||||
CasTreeEntryKind::File => {
|
||||
let Some(blob) = &entry.blob else {
|
||||
conflicts.push(record_apply_conflict(
|
||||
store,
|
||||
&source_root,
|
||||
&entry.path,
|
||||
&tree_hash,
|
||||
"remote file entry is missing a CAS blob hash",
|
||||
dry_run,
|
||||
)?);
|
||||
continue;
|
||||
};
|
||||
if out.exists() {
|
||||
if !out.is_file() {
|
||||
conflicts.push(record_apply_conflict(
|
||||
store,
|
||||
&source_root,
|
||||
&entry.path,
|
||||
&tree_hash,
|
||||
"remote file conflicts with a local non-file path",
|
||||
dry_run,
|
||||
)?);
|
||||
continue;
|
||||
}
|
||||
if hash_path(&out)? != *blob {
|
||||
conflicts.push(record_apply_conflict(
|
||||
store,
|
||||
&source_root,
|
||||
&entry.path,
|
||||
&tree_hash,
|
||||
"local file differs from remote CAS tree; refusing to overwrite",
|
||||
dry_run,
|
||||
)?);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
files_written += 1;
|
||||
if !dry_run {
|
||||
let bytes = cas.read_bytes(blob)?;
|
||||
write_file_atomic(&out, &bytes)?;
|
||||
set_executable_bit(&out, entry.executable)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ControlResponse::CasRootApplied {
|
||||
source: source.to_owned(),
|
||||
target,
|
||||
files_written,
|
||||
dirs_created,
|
||||
conflicts,
|
||||
dry_run,
|
||||
note: "safe file-root apply creates missing paths but never deletes extra files or overwrites differing local files; conflicts are recorded for manual resolution".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn safe_tree_output_path(root: &Path, relative: &str) -> Result<PathBuf, NodeError> {
|
||||
let relative_path = Path::new(relative);
|
||||
if relative_path.is_absolute() {
|
||||
return Err(NodeError::Cas(geth_cas::CasError::NonRelativeTreePath(
|
||||
relative.to_owned(),
|
||||
)));
|
||||
}
|
||||
for component in relative_path.components() {
|
||||
if !matches!(component, Component::Normal(_)) {
|
||||
return Err(NodeError::Cas(geth_cas::CasError::NonRelativeTreePath(
|
||||
relative.to_owned(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(root.join(relative_path))
|
||||
}
|
||||
|
||||
fn record_apply_conflict(
|
||||
store: &Store,
|
||||
root: &StoredFileRoot,
|
||||
path: &str,
|
||||
remote_tree: &BlobHash,
|
||||
detail: &str,
|
||||
dry_run: bool,
|
||||
) -> Result<FileConflict, NodeError> {
|
||||
let created_at = geth_store::now_ms();
|
||||
let stored = StoredFileConflict {
|
||||
conflict_id: generated_file_conflict_id(
|
||||
&root.name,
|
||||
path,
|
||||
FileConflictKind::ConcurrentEdit.as_str(),
|
||||
created_at,
|
||||
),
|
||||
root_name: root.name.clone(),
|
||||
resource_id: root.resource_id.clone(),
|
||||
path: path.to_owned(),
|
||||
kind: FileConflictKind::ConcurrentEdit.as_str().to_owned(),
|
||||
status: FileConflictStatus::Open.as_str().to_owned(),
|
||||
base_tree_hash: None,
|
||||
local_tree_hash: None,
|
||||
remote_tree_hash: Some(remote_tree.to_string()),
|
||||
detail: detail.to_owned(),
|
||||
resolution: None,
|
||||
resolution_note: None,
|
||||
created_at_ms: created_at,
|
||||
resolved_at_ms: None,
|
||||
};
|
||||
if !dry_run {
|
||||
store.upsert_file_conflict(&stored)?;
|
||||
}
|
||||
file_conflict_from_stored(stored)
|
||||
}
|
||||
|
||||
fn write_file_atomic(path: &Path, bytes: &[u8]) -> Result<(), NodeError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension(format!(
|
||||
"geth-tmp-{}",
|
||||
geth_crypto::blake3_hex(path.display().to_string().as_bytes())
|
||||
));
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_executable_bit(path: &Path, executable: bool) -> Result<(), NodeError> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = std::fs::metadata(path)?.permissions();
|
||||
let mut mode = permissions.mode();
|
||||
if executable {
|
||||
mode |= 0o111;
|
||||
} else {
|
||||
mode &= !0o111;
|
||||
}
|
||||
permissions.set_mode(mode);
|
||||
std::fs::set_permissions(path, permissions)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_executable_bit(_path: &Path, _executable: bool) -> Result<(), NodeError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_resource_exists(store: &Store, resource_id: &str) -> Result<(), NodeError> {
|
||||
if store
|
||||
.list_resources()?
|
||||
|
|
|
|||
Loading…
Reference in a new issue