use std::fs;
use std::io::{self, Read as _};
use std::os::unix::fs::OpenOptionsExt as _;
use std::path::Path;
use toml::{Table, Value};
use crate::error::Error;
use crate::shared::{is_eloop, o_nofollow, printable};
const MAX_FLOCKFILE_BYTES: u64 = 1024 * 1024;
pub(crate) const OVERRIDE: &str = "Flockfile.override.toml";
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Follow {
Operators,
Never,
}
pub(super) fn read_required(path: &Path, follow: Follow) -> Result<Value, Error> {
let text = read_flockfile(path, follow)?;
parse(path, &text)
}
pub(super) fn read_optional(path: &Path, follow: Follow) -> Result<Value, Error> {
match read_flockfile(path, follow) {
Ok(text) => parse(path, &text),
Err(Error::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => {
Ok(Value::Table(Table::new()))
}
Err(err) => Err(err),
}
}
fn read_flockfile(path: &Path, follow: Follow) -> Result<String, Error> {
let at = |source| Error::Io {
path: path.to_owned(),
source,
};
let mut options = fs::OpenOptions::new();
options.read(true);
if follow == Follow::Never {
options.custom_flags(o_nofollow());
}
let mut file = match options.open(path) {
Ok(file) => file,
Err(source) if is_eloop(&source) => {
return Err(Error::Config(format!(
"{} is a symlink, and a Flockfile the repository commits has to be a regular \
file: this dog reads it at its own uid before any build drops privilege, so a \
link could point it at any file on the host. Commit the file itself.",
path.display()
)));
}
Err(source) => return Err(at(source)),
};
let len = file.metadata().map_err(at)?.len();
if len > MAX_FLOCKFILE_BYTES {
return Err(Error::Config(format!(
"{} is {len} bytes, past the {MAX_FLOCKFILE_BYTES}-byte limit for a Flockfile. A \
real one is a few hundred bytes; this dog reads and merges both files in memory \
on every deploy.",
path.display()
)));
}
let mut text = String::with_capacity(usize::try_from(len).unwrap_or(0));
file.read_to_string(&mut text).map_err(at)?;
Ok(text)
}
fn parse(path: &Path, text: &str) -> Result<Value, Error> {
toml::from_str(text).map_err(|source| {
let line = source.span().map(|span| {
let upto = span.start.min(text.len());
text.as_bytes()[..upto]
.iter()
.filter(|b| **b == b'\n')
.count()
+ 1
});
let message = printable(source.message());
Error::Config(match line {
Some(line) => format!("{}: {message}, at line {line}", path.display()),
None => format!("{}: {message}", path.display()),
})
})
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::flockfile::app_config;
#[test]
fn a_committed_flockfile_that_is_a_symlink_is_refused_unread() {
let rel = tempfile::tempdir().expect("tempdir");
let secret = rel.path().join("outside.txt");
std::fs::write(&secret, "-----BEGIN SECRET-----\nnot toml\n").expect("secret");
std::os::unix::fs::symlink(&secret, rel.path().join("Flockfile.toml")).expect("link");
let err = app_config(rel.path(), "web", &[]).expect_err("a link must be refused");
let shown = err.to_string();
assert!(matches!(err, Error::Config(_)), "{shown}");
assert!(shown.contains("symlink"), "must say why: {shown}");
assert!(
!shown.contains("SECRET"),
"must not quote the file: {shown}"
);
}
#[test]
fn the_operators_override_is_still_followed() {
let rel = tempfile::tempdir().expect("tempdir");
let checkout = tempfile::tempdir().expect("checkout");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n",
)
.expect("committed");
std::fs::write(
checkout.path().join(OVERRIDE),
"[[app]]\nname = \"web\"\nuser = \"svc\"\n",
)
.expect("the operator's override");
std::os::unix::fs::symlink(checkout.path().join(OVERRIDE), rel.path().join(OVERRIDE))
.expect("linked in, as link_into does");
let app = app_config(rel.path(), "web", &[PathBuf::from(OVERRIDE)])
.expect("the operator's link resolves");
assert_eq!(app.user.as_deref(), Some("svc"));
}
#[test]
fn a_parse_error_names_the_line_and_not_its_text() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\n-----BEGIN OPENSSH PRIVATE KEY-----\n",
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("not toml");
let shown = err.to_string();
assert!(shown.contains("line 3"), "must name the line: {shown}");
assert!(!shown.contains("OPENSSH"), "must not quote it: {shown}");
}
#[test]
fn a_flockfile_past_the_size_limit_is_refused() {
let rel = tempfile::tempdir().expect("tempdir");
let mut big = String::from("[[app]]\nname = \"web\"\nscript = \"./run\"\n");
big.push_str(&"# padding\n".repeat(120_000));
assert!(
big.len() as u64 > MAX_FLOCKFILE_BYTES,
"the fixture must exceed the limit"
);
std::fs::write(rel.path().join("Flockfile.toml"), big).expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("too big");
assert!(err.to_string().contains("limit"), "{err}");
}
#[test]
fn a_committed_symlink_out_of_the_release_buys_no_exemption() {
let tree = tempfile::tempdir().expect("tempdir");
let a = tree.path().join("releases/shaA");
let b = tree.path().join("releases/shaB");
std::fs::create_dir_all(&a).expect("a");
std::fs::create_dir_all(&b).expect("b");
std::fs::write(
a.join(".deploy-payload.toml"),
"[[app]]\nname = \"web\"\nuser = \"root\"\n",
)
.expect("payload");
std::os::unix::fs::symlink(&a, tree.path().join("current")).expect("current");
std::fs::write(
b.join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n",
)
.expect("committed");
std::os::unix::fs::symlink(
"../../current/.deploy-payload.toml",
b.join("Flockfile.override.toml"),
)
.expect("committed symlink");
let err =
app_config(&b, "web", &[]).expect_err("a committed symlink must not buy the exemption");
let shown = format!("{err}");
assert!(
shown.contains("symlink"),
"the link is refused before it is followed: {shown}"
);
assert!(
!shown.contains("root"),
"and nothing of the payload is read: {shown}"
);
}
}