///|
/// Reject symbolic links at every component before each operation.
fn confined(root : String, path : String) -> String raise {
let path = @workspace.normalize(path)
let mut current = root
for part in path.split("/") {
if part == "" {
continue
}
current = current + "/" + part.to_owned()
let kind = @platform.kind(current)
if kind == 3 || kind == 4 {
raise @config.Invalid("Unsupported link or special file: " + current)
}
}
current
}
///|
pub fn Mount::filesystem(root : String) -> Mount raise {
if @platform.kind(root) != 2 {
raise @config.Invalid("Mount must be a directory: " + root)
}
{
kind: fn(path) raise { @platform.kind(confined(root, path)) },
read: fn(path) raise {
let file = confined(root, path)
if @platform.kind(file) != 1 {
raise @config.Invalid("Expected a regular file: " + path)
}
@platform.read(file)
},
list: fn(path) raise { @platform.list(confined(root, path)) },
}
}
///|
fn ensure_dirs(root : String, path : String) -> Unit raise {
let path = @workspace.normalize(path)
let mut current = ""
for component in path.split("/") {
if component == "" {
continue
}
current = @workspace.join(current, component.to_owned())
let full = confined(root, current)
match @platform.kind(full) {
0 => @platform.mkdir(full)
2 => ()
_ => raise @config.Invalid("Expected output directory: " + full)
}
}
}
///|
fn delete_owned(root : String, path : String) -> Unit raise {
let full = confined(root, path)
match @platform.kind(full) {
0 => ()
1 => @platform.remove_file(full)
2 => {
for name in @platform.list(full) {
delete_owned(root, @workspace.join(path, name))
}
@platform.remove_dir(full)
}
_ => raise @config.Invalid("Refusing to remove nonregular staging entry")
}
}
///|
fn relative_to(root : String, path : String) -> String raise {
if !@workspace.within(root, path) || path == root {
raise @config.Invalid("Artifact outside output root: " + path)
}
if root == "" {
path
} else {
path[root.length() + 1:].to_owned()
}
}
///|
pub fn ContentPublisher::filesystem(
root : String,
output_root : String,
) -> ContentPublisher raise {
filesystem_content_publisher(root, output_root, @platform.rename)
}
///|
/// Original inventories can include empty directories. They use the same
/// confined transaction, locking, replacement and rollback as generated files.
pub(all) enum DestinationPolicy {
Replace
Create
}
///|
pub fn ContentPublisher::filesystem_inventory(
root : String,
output_root : String,
directories : Array[String],
destination_policy~ : DestinationPolicy = Replace,
) -> ContentPublisher raise {
filesystem_content_publisher(
root,
output_root,
@platform.rename,
directories~,
destination_policy~,
)
}
///|
// Keep the default filesystem operations fixed; whitebox fault tests can fail
// the publish and restore operations independently without timing filesystem races.
fn filesystem_content_publisher(
root : String,
output_root : String,
rename : (String, String) -> Unit raise,
directories~ : Array[String] = [],
destination_policy~ : DestinationPolicy = Replace,
) -> ContentPublisher raise {
let output_root = @workspace.normalize(output_root)
if output_root == "" {
raise @config.Invalid("Publication root cannot be empty")
}
{
guarantee: RecoverableTask,
write_bytes: @platform.bytes_supported(),
begin: fn(destinations) raise {
ensure_dirs(root, output_root)
let lock = @workspace.join(output_root, ".morphir-publish.lock")
// Exclusive mkdir serializes publishers. Existing locks require explicit recovery.
@platform.mkdir(confined(root, lock))
let stage = @workspace.join(lock, "stage")
let backup = @workspace.join(lock, "backup")
errdefer delete_owned(root, lock)
{
ensure_dirs(root, stage)
ensure_dirs(root, backup)
for destination in destinations {
let target = @workspace.join(
stage,
relative_to(output_root, destination),
)
ensure_dirs(root, @workspace.parent(target))
@platform.mkdir(confined(root, target))
}
for directory in directories {
if !destinations.any(destination => {
@workspace.within(destination, directory)
}) {
raise @config.Invalid("Directory outside planned destination")
}
ensure_dirs(
root,
@workspace.join(stage, relative_to(output_root, directory)),
)
}
}
let done = Map([])
let recovery = Ref(false)
{
stage: fn(artifact) raise {
if !destinations.any(d => @workspace.within(d, artifact.path)) {
raise @config.Invalid("Artifact outside planned destination")
}
let target = @workspace.join(
stage,
relative_to(output_root, artifact.path),
)
ensure_dirs(root, @workspace.parent(target))
if @platform.kind(confined(root, target)) != 0 {
raise @config.Invalid(
"Output paths collide on this filesystem: " + artifact.path,
)
}
match artifact.content {
Text(text) => @platform.write(confined(root, target), text)
Binary(bytes) =>
@platform.write_bytes(confined(root, target), bytes)
}
},
commit: fn(destination) raise {
if !destinations.contains(destination) || done.contains(destination) {
raise @config.Invalid("Invalid publication destination")
}
let rel = relative_to(output_root, destination)
let staged = @workspace.join(stage, rel)
let saved = @workspace.join(backup, rel)
ensure_dirs(root, @workspace.parent(destination))
ensure_dirs(root, @workspace.parent(saved))
if destination_policy is Create {
@platform.rename_directory_new(
confined(root, staged),
confined(root, destination),
)
done[destination] = true
return
}
let existing = @platform.kind(confined(root, destination))
if existing != 0 && existing != 2 {
raise @config.Invalid("Destination is not a directory")
}
if existing == 2 {
rename(confined(root, destination), confined(root, saved))
}
let publication = Ok(
rename(confined(root, staged), confined(root, destination)),
) catch {
error => Err(error)
}
match publication {
Ok(_) => ()
Err(cause) => {
if existing == 2 {
rename(confined(root, saved), confined(root, destination)) catch {
rollback => {
recovery.val = true
raise @config.Invalid(
"Publication failed: " +
error_message(cause) +
"; Rollback failed; preserved backup at " +
saved +
": " +
error_message(rollback),
)
}
}
}
raise cause
}
}
done[destination] = true
},
abort: fn() raise {
if recovery.val {
raise @config.Invalid(
"Manual recovery required; preserving " + lock,
)
}
delete_owned(root, lock)
},
}
},
}
}
///|
pub fn error_message(error : Error) -> String {
match error {
@protocol.Invalid(message) => message
@linked_metadata.Invalid(code, context, detail) =>
code + ": " + context + " " + detail
@moonbit.Invalid(code, context, detail) =>
code + ": " + context + " " + detail
@data.Invalid(code, detail) => code + ": " + detail
@config.Invalid(message) => message
@platform.Interrupted => "Cancelled"
@platform.IOError(message) => message
@toolchain.ToolchainError(message) => message
_ => error.to_string()
}
}
///|
/// Stable domain codes for machine-readable host adapters. Keep the typed
/// error's code instead of reconstructing it from its formatted message.
pub fn error_code(error : Error) -> String {
match error {
@platform.Interrupted => "host.invalid_input"
// These compatibility errors carry a code followed by optional context.
// The human message retains that context; machine code stays a code.
@protocol.Invalid(detail) | @execution.AdmissionInvalid(detail) =>
detail.split(":").next().unwrap_or("execution.plan").to_owned()
@linked_metadata.Invalid(code, _, _) => code
@moonbit.Invalid(code, _, _) => code
@data.Invalid(code, _) => code
@platform.IOError(detail) | @config.Invalid(detail) => {
let code = detail.split(":").next().unwrap_or("").to_owned()
if code.contains(".") &&
code.length() <= 128 &&
code
.to_array()
.all(c => {
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '.' ||
c == '_'
}) {
code
} else {
"host.invalid_input"
}
}
_ => "execution.plan"
}
}
///|
/// Keep the text publisher API as an adapter over the same transaction.
pub fn Publisher::filesystem(
root : String,
output_root : String,
) -> Publisher raise {
let publisher = ContentPublisher::filesystem(root, output_root)
{
guarantee: publisher.guarantee,
begin: fn(destinations) raise {
let publication = (publisher.begin)(destinations)
{
stage: fn(artifact) raise {
(publication.stage)({
path: artifact.path,
source: artifact.source,
content: @data.Text(artifact.content),
format: "text",
})
},
commit: publication.commit,
abort: publication.abort,
}
},
}
}
///|
pub fn Mount::filesystem_bytes(
root : String,
) -> ((String) -> Bytes raise) raise {
if @platform.kind(root) != 2 {
raise @config.Invalid("Mount must be a directory: " + root)
}
if !@platform.bytes_supported() {
raise @config.Invalid(
"host.byte_read_required: Embedding host does not advertise byte operations",
)
}
fn(path) raise {
let file = confined(root, path)
if @platform.kind(file) != 1 {
raise @config.Invalid("Expected a regular file: " + path)
}
@platform.read_bytes(file)
}
}