blob: eedd273fb24f20bca5f8612c443eef80adb33ab4 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! A bounded text file explicitly granted by native instance configuration.
//! Commands cannot choose paths. Accepted work drains after the view retires.
use crate::budget::{Kind, NativeBudget, Permit};
use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt};
use cap_std::{
ambient_authority,
fs::{Dir, OpenOptions},
};
use maka_capabilities::call::Resources;
use maka_plugin_core::contract::{Contract, Interface, Transport};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
io::{Read, Write},
path::{Path, PathBuf},
sync::{Arc, mpsc},
};
#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextFileConfiguration {
#[schemars(length(min = 1))]
pub path: Option<PathBuf>,
}
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextFileProps {
pub command: Option<TextFileCommand>,
}
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextFileCommand {
pub owner: String,
pub sequence: u64,
pub operation: TextFileOperation,
}
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
pub enum TextFileOperation {
Read,
Write {
#[serde(rename = "expectedDigest")]
expected_digest: Option<String>,
text: String,
},
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextFileObservation {
pub owner: String,
pub path: Option<String>,
pub sequence: u64,
pub state: TextFileState,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum TextFileState {
Unavailable,
Idle,
Loading,
Saving,
Ready {
digest: Option<String>,
text: Option<String>,
},
Failed {
error: TextFileError,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TextFileError {
ReadFailed,
WriteFailed,
InvalidFile,
InvalidUtf8,
Conflict,
WorkerFailed,
}
pub fn contract() -> Contract {
Contract {
name: "maka.ui.TextFile".into(),
version: 1,
transport: Transport::Local,
interface: Interface::Component {
props: serde_json::to_value(schemars::schema_for!(TextFileProps))
.unwrap()
.into(),
events: BTreeMap::from([(
"changed".into(),
serde_json::to_value(schemars::schema_for!(TextFileObservation))
.unwrap()
.into(),
)]),
captures: BTreeMap::new(),
},
}
}
fn read(
directory: &Dir,
name: &std::ffi::OsStr,
budget: &NativeBudget,
) -> Result<(Option<String>, Option<Permit>), TextFileError> {
let mut options = OpenOptions::new();
options.read(true).follow(FollowSymlinks::No);
#[cfg(unix)]
{
use cap_std::fs::OpenOptionsExt;
options.custom_flags(libc::O_NONBLOCK);
}
let file = match directory.open_with(name, &options) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((None, None)),
Err(_) => return Err(TextFileError::ReadFailed),
};
let metadata = file.metadata().map_err(|_| TextFileError::ReadFailed)?;
if !metadata.is_file() {
return Err(TextFileError::InvalidFile);
}
let length = usize::try_from(metadata.len()).map_err(|_| TextFileError::WorkerFailed)?;
let admitted = length
.checked_add(1)
.and_then(|n| n.checked_mul(6))
.ok_or(TextFileError::WorkerFailed)?;
let mut permit = budget
.reserve(Kind::Encoded, admitted)
.map_err(|_| TextFileError::WorkerFailed)?;
let mut bytes = Vec::with_capacity(length);
file.take(
u64::try_from(length)
.map_err(|_| TextFileError::WorkerFailed)?
.checked_add(1)
.ok_or(TextFileError::WorkerFailed)?,
)
.read_to_end(&mut bytes)
.map_err(|_| TextFileError::ReadFailed)?;
if bytes.len() != length {
return Err(TextFileError::Conflict);
}
let text = String::from_utf8(bytes).map_err(|_| TextFileError::InvalidUtf8)?;
let encoded = serde_json::to_vec(&text)
.map_err(|_| TextFileError::WorkerFailed)?
.len();
permit.shrink_to(encoded);
Ok((Some(text), Some(permit)))
}
fn digest(text: Option<&str>) -> Option<String> {
text.map(|text| format!("sha256:{}", hex::encode(Sha256::digest(text.as_bytes()))))
}
fn perform_admitted(
path: &Path,
operation: TextFileOperation,
budget: &NativeBudget,
) -> Result<(TextFileState, Option<Permit>), TextFileError> {
if !path.is_absolute() {
return Err(TextFileError::InvalidFile);
}
let parent = path.parent().ok_or(TextFileError::InvalidFile)?;
let name = path.file_name().ok_or(TextFileError::InvalidFile)?;
if matches!(operation, TextFileOperation::Write { .. }) {
std::fs::create_dir_all(parent).map_err(|_| TextFileError::WriteFailed)?;
}
let directory = match Dir::open_ambient_dir(parent, ambient_authority()) {
Ok(directory) => directory,
Err(error)
if error.kind() == std::io::ErrorKind::NotFound
&& matches!(operation, TextFileOperation::Read) =>
{
return Ok((
TextFileState::Ready {
digest: None,
text: None,
},
None,
));
}
Err(_) => return Err(TextFileError::ReadFailed),
};
let (current, mut retained) = read(&directory, name, budget)?;
let text = match operation {
TextFileOperation::Read => current,
TextFileOperation::Write {
expected_digest,
text,
} => {
if digest(current.as_deref()) != expected_digest {
return Err(TextFileError::Conflict);
}
drop(current);
retained = None;
let temporary = format!(".maka-text-{}", uuid::Uuid::new_v4());
let result = (|| {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use cap_std::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = directory
.open_with(&temporary, &options)
.map_err(|_| TextFileError::WriteFailed)?;
file.write_all(text.as_bytes())
.and_then(|()| file.sync_all())
.map_err(|_| TextFileError::WriteFailed)?;
// Detect ordinary external edits. This is not an atomic CAS against
// arbitrary external writers between the check and rename.
let (checked, _checking) = read(&directory, name, budget)?;
if digest(checked.as_deref()) != expected_digest {
return Err(TextFileError::Conflict);
}
directory
.rename(&temporary, &directory, name)
.map_err(|_| TextFileError::WriteFailed)
})();
let _ = directory.remove_file(&temporary);
result?;
Some(text)
}
};
Ok((
TextFileState::Ready {
digest: digest(text.as_deref()),
text,
},
retained,
))
}
struct Reply {
state: TextFileState,
_bytes: Vec<Permit>,
}
pub struct TextFile {
path: Option<PathBuf>,
resources: Arc<Resources>,
budget: NativeBudget,
receiver: Option<mpsc::Receiver<Reply>>,
bytes: Vec<Permit>,
current: TextFileObservation,
announced: Option<TextFileObservation>,
closed: bool,
}
impl TextFile {
pub fn new(path: Option<PathBuf>, resources: Arc<Resources>, budget: NativeBudget) -> Self {
Self {
current: TextFileObservation {
owner: uuid::Uuid::new_v4().to_string(),
path: path
.as_ref()
.map(|path| path.to_string_lossy().into_owned()),
sequence: 0,
state: if path.is_some() {
TextFileState::Idle
} else {
TextFileState::Unavailable
},
},
path,
resources,
budget,
receiver: None,
bytes: Vec::new(),
announced: None,
closed: false,
}
}
pub fn will_update(&self, props: &TextFileProps) -> bool {
!self.closed
&& self.path.is_some()
&& props.command.as_ref().is_some_and(|command| {
command.owner == self.current.owner && command.sequence > self.current.sequence
})
}
pub fn update(&mut self, props: &TextFileProps) {
let Some(command) = &props.command else {
return;
};
if self.closed
|| self.receiver.is_some()
|| command.owner != self.current.owner
|| command.sequence <= self.current.sequence
{
return;
}
let Some(path) = self.path.clone() else {
return;
};
self.current.sequence = command.sequence;
let result = (|| {
let mut bytes = Vec::new();
if let TextFileOperation::Write { text, .. } = &command.operation {
let length = maka_plugin_core::json::encoded_size(text, usize::MAX)
.map_err(|_| TextFileError::WorkerFailed)?;
bytes.push(
self.budget
.reserve(Kind::Encoded, length)
.map_err(|_| TextFileError::WorkerFailed)?,
);
}
let budget = self.budget.clone();
let mut ticket = self
.resources
.reserve()
.map_err(|_| TextFileError::WorkerFailed)?;
let wake = self.resources.clone();
let operation = command.operation.clone();
let (sender, receiver) = mpsc::sync_channel(1);
std::thread::Builder::new()
.name("native-text-file".into())
.spawn(move || {
ticket.start();
let result =
std::panic::catch_unwind(|| perform_admitted(&path, operation, &budget));
let (state, cleanup) = match result {
Ok(Ok((state, retained))) => {
bytes.extend(retained);
(state, Ok(()))
}
Ok(Err(error)) => (TextFileState::Failed { error }, Ok(())),
Err(_) => (
TextFileState::Failed {
error: TextFileError::WorkerFailed,
},
Err("text file worker panicked".into()),
),
};
let _ = sender.send(Reply {
state,
_bytes: bytes,
});
wake.notify();
ticket.complete(cleanup);
})
.map_err(|_| TextFileError::WorkerFailed)?;
self.receiver = Some(receiver);
Ok(if matches!(command.operation, TextFileOperation::Read) {
TextFileState::Loading
} else {
TextFileState::Saving
})
})();
self.current.state = result.unwrap_or_else(|error| TextFileState::Failed { error });
self.bytes.clear();
}
fn poll(&mut self) {
let Some(receiver) = &self.receiver else {
return;
};
match receiver.try_recv() {
Ok(reply) => {
self.current.state = reply.state;
self.bytes = reply._bytes;
self.receiver = None;
}
Err(mpsc::TryRecvError::Disconnected) => {
self.current.state = TextFileState::Failed {
error: TextFileError::WorkerFailed,
};
self.receiver = None;
}
Err(mpsc::TryRecvError::Empty) => {}
}
}
pub fn observe(&mut self) -> Option<TextFileObservation> {
self.poll();
if self.announced.as_ref() == Some(&self.current) {
return None;
}
self.announced = Some(self.current.clone());
Some(self.current.clone())
}
pub fn close(&mut self) {
self.closed = true;
}
}
pub const IMPLEMENTATION: &str = "maka.ui.text-file";
pub fn native() -> maka_plugin_core::package::NativeDefinition {
maka_plugin_core::package::NativeDefinition::new(
IMPLEMENTATION,
schemars::schema_for!(TextFileConfiguration).into(),
[("TextFile", contract())],
)
}
#[cfg(test)]
mod tests {
fn perform(
path: &std::path::Path,
operation: super::TextFileOperation,
) -> Result<super::TextFileState, super::TextFileError> {
super::perform_admitted(path, operation, &super::NativeBudget::default())
.map(|(state, _)| state)
}
use super::*;
fn write(expected: Option<&str>, text: &str) -> TextFileOperation {
TextFileOperation::Write {
expected_digest: expected.map(str::to_owned),
text: text.into(),
}
}
#[test]
fn complete_atomic_replacement_detects_external_changes_and_preserves_original() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("nested/theme.json");
assert_eq!(
perform(&path, TextFileOperation::Read),
Ok(TextFileState::Ready {
digest: None,
text: None
})
);
let original = "{\"name\":\"夜色\"}\n";
let revision = digest(Some(original)).unwrap();
assert_eq!(
perform(&path, write(None, original)),
Ok(TextFileState::Ready {
digest: Some(revision.clone()),
text: Some(original.into())
})
);
std::fs::write(&path, "external").unwrap();
assert_eq!(
perform(&path, write(Some(&revision), "replaced")),
Err(TextFileError::Conflict)
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "external");
let external = digest(Some("external")).unwrap();
let exact = "色😀".repeat(4000) + "COMPLETE END";
assert!(exact.len() > 16 * 1024);
assert!(perform(&path, write(Some(&external), &exact)).is_ok());
assert_eq!(
perform(&path, write(Some(&external), &(exact.clone() + "x"))),
Err(TextFileError::Conflict)
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), exact);
assert_eq!(
std::fs::read_dir(path.parent().unwrap()).unwrap().count(),
1
);
}
#[test]
fn reads_reject_non_regular_files_and_invalid_utf8_without_following_links() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("file");
std::fs::write(&path, [255]).unwrap();
assert_eq!(
perform(&path, TextFileOperation::Read),
Err(TextFileError::InvalidUtf8)
);
assert_eq!(
perform(directory.path(), TextFileOperation::Read),
Err(TextFileError::InvalidFile)
);
assert_eq!(
perform(Path::new("relative"), TextFileOperation::Read),
Err(TextFileError::InvalidFile)
);
#[cfg(unix)]
{
let link = directory.path().join("link");
std::os::unix::fs::symlink(&path, &link).unwrap();
assert!(perform(&link, TextFileOperation::Read).is_err());
assert!(perform(&link, write(None, "overwrite")).is_err());
std::fs::remove_file(&path).unwrap();
assert!(perform(&link, write(None, "overwrite")).is_err());
assert!(link.symlink_metadata().unwrap().file_type().is_symlink());
let fifo = directory.path().join("fifo");
let name = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
assert_eq!(
perform(&fifo, TextFileOperation::Read),
Err(TextFileError::InvalidFile)
);
}
}
#[tokio::test]
async fn admitted_write_drains_after_original_owner_retires_and_budget_failure_never_writes() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("theme.json");
let resources = Arc::new(Resources::default());
let budget = NativeBudget::default();
let mut owner = TextFile::new(Some(path.clone()), resources.clone(), budget.clone());
let mut command = TextFileCommand {
owner: "other-owner".into(),
sequence: 1,
operation: write(None, "accepted"),
};
owner.update(&TextFileProps {
command: Some(command.clone()),
});
assert_eq!(owner.observe().unwrap().state, TextFileState::Idle);
command.owner = owner.current.owner.clone();
owner.update(&TextFileProps {
command: Some(command.clone()),
});
owner.close();
command.sequence += 1;
command.operation = write(None, "late");
owner.update(&TextFileProps {
command: Some(command),
});
drop(owner);
resources.finish().await.unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "accepted");
assert_eq!(budget.usage(), (0, 0, 0));
let budget = NativeBudget::new(crate::budget::BudgetLimits {
encoded_bytes: 1,
..Default::default()
});
let resources = Arc::new(Resources::default());
let mut owner = TextFile::new(Some(path.clone()), resources.clone(), budget.clone());
owner.update(&TextFileProps {
command: Some(TextFileCommand {
owner: owner.current.owner.clone(),
sequence: 1,
operation: write(digest(Some("accepted")).as_deref(), "denied"),
}),
});
assert_eq!(
owner.observe().unwrap().state,
TextFileState::Failed {
error: TextFileError::WorkerFailed
}
);
resources.finish().await.unwrap();
assert_eq!(budget.usage(), (0, 0, 0));
assert_eq!(std::fs::read_to_string(path).unwrap(), "accepted");
}
}