Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions crates/xgen-adapter-filesystem/src/apply_patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ use xgen_runtime::{
AdapterPrepareFailure, AdapterPrepareRequest, AdapterReconcileRequest,
AdapterReconciliationInconclusiveReason, AdapterReconciliationObservation, AdapterToolOutput,
EffectAdapter, EffectVerifier, PreparedAdapterInvocation, RuleVerificationObservation,
VerificationPortFailure, VerificationReport, VerificationRequest, VerifiedArtifactDescriptor,
VerifierOutputDigest,
StepFailureCode, VerificationPortFailure, VerificationReport, VerificationRequest,
VerifiedArtifactDescriptor, VerifierOutputDigest,
};
use xgen_workgraph::EffectClass;

use crate::atomic_commit::{AtomicCommitFailure, MAX_ATOMIC_TEXT_BYTES, commit_atomic};
use crate::path::{RelativePath, parse_canonical};
use crate::read_text::{ReadTextLimits, read_text, sha256_digest};
use crate::read_text::{ReadFailure, ReadTextLimits, read_text, sha256_digest};
use crate::{APPLY_PATCH_CAPABILITY_ID, APPLY_PATCH_CONTRACT_VERSION, WorkspaceRoot};

/// Maximum total UTF-8 bytes across every `oldText` and `newText` patch field.
Expand Down Expand Up @@ -214,6 +214,7 @@ impl PreparedAdapterInvocation for PreparedApplyPatch {
Err(failure) => AdapterExecutionObservation::Failed {
evidence_digest: AdapterEvidenceDigest::new(failure.evidence_digest())
.expect("a SHA-256 digest is canonical"),
failure_code: failure.failure_code(),
},
}
}
Expand All @@ -234,7 +235,7 @@ struct InspectedOutput {

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PatchFailure {
ReadSource,
ReadSource(ReadFailure),
PreconditionConflict,
ContextMissing,
ContextAmbiguous,
Expand All @@ -246,7 +247,7 @@ enum PatchFailure {
impl PatchFailure {
fn evidence_digest(self) -> String {
let code = match self {
Self::ReadSource => "read-source",
Self::ReadSource(_) => "read-source",
Self::PreconditionConflict => "precondition-conflict",
Self::ContextMissing => "context-missing",
Self::ContextAmbiguous => "context-ambiguous",
Expand All @@ -256,6 +257,18 @@ impl PatchFailure {
};
sha256_digest(format!("xgeny.fs/apply-patch/failure/v1/{code}").as_bytes())
}

const fn failure_code(self) -> StepFailureCode {
match self {
Self::ReadSource(failure) => failure.failure_code(),
Self::PreconditionConflict => StepFailureCode::PreconditionConflict,
Self::ContextMissing => StepFailureCode::PatchContextMissing,
Self::ContextAmbiguous => StepFailureCode::PatchContextAmbiguous,
Self::OverlappingEdits => StepFailureCode::PatchEditsOverlap,
Self::ResultTooLarge => StepFailureCode::TooLarge,
Self::Atomic(failure) => failure.failure_code(),
}
}
}

fn verify_contract(
Expand Down Expand Up @@ -426,7 +439,7 @@ fn apply_patch_to_file(
edits: &[PatchEdit],
) -> Result<PatchCommit, PatchFailure> {
let observed = read_text(root, relative_path, ReadTextLimits::default())
.map_err(|_| PatchFailure::ReadSource)?;
.map_err(PatchFailure::ReadSource)?;
if observed.digest != expected_digest {
return Err(PatchFailure::PreconditionConflict);
}
Expand Down Expand Up @@ -575,29 +588,35 @@ mod tests {
sha256_digest(b"stale"),
vec![edit("two", "TWO")],
PatchFailure::PreconditionConflict,
StepFailureCode::PreconditionConflict,
),
(
"one two one",
sha256_digest(b"one two one"),
vec![edit("missing", "new")],
PatchFailure::ContextMissing,
StepFailureCode::PatchContextMissing,
),
(
"one two one",
sha256_digest(b"one two one"),
vec![edit("one", "ONE")],
PatchFailure::ContextAmbiguous,
StepFailureCode::PatchContextAmbiguous,
),
(
"abcdef",
sha256_digest(b"abcdef"),
vec![edit("abc", ""), edit("cde", "")],
PatchFailure::OverlappingEdits,
StepFailureCode::PatchEditsOverlap,
),
];
for (source, expected, edits, failure) in cases {
for (source, expected, edits, failure, code) in cases {
let fixture = Fixture::new(source);
assert_eq!(fixture.apply(&expected, &edits).unwrap_err(), failure);
let observed = fixture.apply(&expected, &edits).unwrap_err();
assert_eq!(observed, failure);
assert_eq!(observed.failure_code(), code);
assert_eq!(fixture.content(), source);
}
}
Expand Down
32 changes: 28 additions & 4 deletions crates/xgen-adapter-filesystem/src/atomic_commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ use cap_fs_ext::OpenOptionsMaybeDirExt as _;
use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _, OpenOptionsSyncExt as _};
use cap_std::fs::{Dir, File, OpenOptions, Permissions};

use crate::path::RelativePath;
use xgen_runtime::StepFailureCode;

use crate::path::{OpenErrorClass, RelativePath, classify_open_error};
use crate::read_text::sha256_digest;

pub(crate) const MAX_ATOMIC_TEXT_BYTES: usize = 64 * 1024;
const TEMP_CREATE_ATTEMPTS: usize = 8;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AtomicCommitFailure {
ParentNotFound,
OpenParent,
InspectTarget,
Conflict,
Expand All @@ -27,6 +30,7 @@ pub(crate) enum AtomicCommitFailure {
impl AtomicCommitFailure {
pub(crate) const fn code(self) -> &'static str {
match self {
Self::ParentNotFound => "parent-not-found",
Self::OpenParent => "open-parent",
Self::InspectTarget => "inspect-target",
Self::Conflict => "precondition-conflict",
Expand All @@ -38,6 +42,21 @@ impl AtomicCommitFailure {
}
}

/// Classification of a definite failure. Every definite failure happens before the commit
/// rename, so the target is unchanged.
pub(crate) const fn failure_code(self) -> StepFailureCode {
match self {
Self::ParentNotFound => StepFailureCode::NotFound,
Self::OpenParent | Self::InspectTarget => StepFailureCode::Unavailable,
Self::Conflict => StepFailureCode::PreconditionConflict,
Self::CreateTemporary
| Self::WriteTemporary
| Self::CommitRenameUnknown
| Self::CommitSyncUnknown
| Self::CommitVerifyUnknown => StepFailureCode::IoError,
}
}

pub(crate) const fn outcome_is_unknown(self) -> bool {
matches!(
self,
Expand Down Expand Up @@ -122,9 +141,14 @@ fn open_parent(root: &Dir, path: &RelativePath) -> Result<(Dir, String), AtomicC
.try_clone()
.map_err(|_| AtomicCommitFailure::OpenParent)?;
for component in parents {
directory = directory
.open_dir_nofollow(component)
.map_err(|_| AtomicCommitFailure::OpenParent)?;
directory = directory.open_dir_nofollow(component).map_err(|error| {
match classify_open_error(&directory, component, &error) {
OpenErrorClass::Missing | OpenErrorClass::NotADirectory => {
AtomicCommitFailure::ParentNotFound
}
OpenErrorClass::Refused => AtomicCommitFailure::OpenParent,
}
})?;
if is_windows_reparse_point(
&directory
.dir_metadata()
Expand Down
32 changes: 32 additions & 0 deletions crates/xgen-adapter-filesystem/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,38 @@ impl std::fmt::Debug for RelativePath {
}
}

/// Value-free class of an error from opening one path component without following links.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OpenErrorClass {
/// Nothing exists at the name.
Missing,
/// Something other than a directory exists at the name.
NotADirectory,
/// The name exists but is refused, such as a symbolic link, or the error is not classified.
Refused,
}

/// Classify an open error for `name` in `parent`. Some platforms report a symbolic link opened
/// without following as "not a directory"; that link exists and is refused, so it is not
/// reported as a wrong kind.
pub(crate) fn classify_open_error(
parent: &cap_std::fs::Dir,
name: &str,
error: &std::io::Error,
) -> OpenErrorClass {
match error.kind() {
std::io::ErrorKind::NotFound => OpenErrorClass::Missing,
std::io::ErrorKind::NotADirectory
if !parent
.symlink_metadata(name)
.is_ok_and(|metadata| metadata.file_type().is_symlink()) =>
{
OpenErrorClass::NotADirectory
}
_ => OpenErrorClass::Refused,
}
}

pub(crate) fn canonical_resource(
workspace_id: &WorkspaceId,
resource: &str,
Expand Down
Loading