use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::config::GENERATE_CONFIG_FILENAME;
pub const MANIFEST_PATH: &str = ".autumn/generated.toml";
const UNIT_SEPARATOR: char = '\u{1f}';
const RECORD_SEPARATOR: char = '\u{1e}';
fn normalize(contents: &str) -> String {
contents.replace("\r\n", "\n")
}
#[must_use]
pub fn text_digest(contents: &str) -> String {
digest(normalize(contents).as_bytes())
}
#[must_use]
pub fn bytes_digest(bytes: &[u8]) -> String {
digest(bytes)
}
fn digest(bytes: &[u8]) -> String {
use sha2::{Digest as _, Sha256};
hex::encode(Sha256::digest(bytes))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Entry {
digest: String,
invocation: String,
}
#[derive(Default, Serialize, Deserialize)]
struct ManifestFile {
#[serde(default)]
files: BTreeMap<String, Entry>,
}
#[must_use]
pub fn current_invocation(root: &Path) -> String {
let args: Vec<String> = std::env::args_os()
.skip(1)
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
let inputs = normalize_invocation(args.iter().cloned());
let config = config_fingerprint(root, &args);
let backend = backend_fingerprint(root);
format!("{inputs}{RECORD_SEPARATOR}{config}{RECORD_SEPARATOR}{backend}")
}
#[must_use]
pub fn resolved_invocation(root: &Path, parts: &[&str]) -> String {
let inputs = parts.join(&UNIT_SEPARATOR.to_string());
let backend = backend_fingerprint(root);
format!("{inputs}{RECORD_SEPARATOR}{backend}")
}
fn backend_fingerprint(root: &Path) -> &'static str {
match super::detect_backend(root) {
autumn_web::config::DatabaseBackend::Postgres => "postgres",
autumn_web::config::DatabaseBackend::Sqlite => "sqlite",
}
}
fn config_fingerprint(root: &Path, args: &[String]) -> String {
let mut sources = vec![root.join(GENERATE_CONFIG_FILENAME)];
let mut next_is_path = false;
for arg in args {
if next_is_path {
sources.push(PathBuf::from(arg));
next_is_path = false;
} else if let Some(path) = arg.strip_prefix("--config=") {
sources.push(PathBuf::from(path));
} else if arg == "--config" {
next_is_path = true;
}
}
let mut material = String::new();
let mut seen: Vec<&Path> = Vec::new();
for source in &sources {
if seen.contains(&source.as_path()) {
continue; }
seen.push(source);
let contents = std::fs::read(source).map_or_else(
|_| "absent".to_owned(),
|bytes| std::str::from_utf8(&bytes).map_or_else(|_| digest(&bytes), text_digest),
);
material.push_str(&contents);
material.push(RECORD_SEPARATOR);
}
digest(material.as_bytes())
}
fn normalize_invocation(args: impl Iterator<Item = String>) -> String {
let mut verb_seen = false;
let mut parts = Vec::new();
for arg in args {
if !verb_seen && (arg == "generate" || arg == "destroy") {
verb_seen = true;
continue;
}
if matches!(arg.as_str(), "--force" | "--dry-run") {
continue;
}
parts.push(arg);
}
parts.join(&UNIT_SEPARATOR.to_string())
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Provenance {
entries: BTreeMap<String, Entry>,
}
impl Provenance {
#[must_use]
pub fn load(root: &Path) -> Self {
if refuse_symlink(root, &root.join(MANIFEST_PATH)).is_err() {
return Self::default();
}
let text = std::fs::read_to_string(root.join(MANIFEST_PATH)).unwrap_or_default();
let file: ManifestFile = toml::from_str(&text).unwrap_or_default();
Self {
entries: file.files,
}
}
#[cfg(test)]
pub fn contains(&self, key: &str) -> bool {
self.entries.contains_key(key)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn is_ours(&self, root: &Path, path: &Path, digest: &str, invocation: &str) -> bool {
key(root, path).is_some_and(|k| {
self.entries
.get(&k)
.is_some_and(|e| e.digest == digest && e.invocation == invocation)
})
}
#[must_use]
pub fn was_written(&self, root: &Path, path: &Path, digest: &str) -> bool {
key(root, path).is_some_and(|k| self.entries.get(&k).is_some_and(|e| e.digest == digest))
}
pub fn record(&mut self, root: &Path, path: &Path, digest: String, invocation: &str) {
if let Some(k) = key(root, path) {
self.entries.insert(
k,
Entry {
digest,
invocation: invocation.to_owned(),
},
);
}
}
pub fn forget(&mut self, root: &Path, path: &Path) {
if let Some(k) = key(root, path) {
self.entries.remove(&k);
}
}
pub fn forget_dir(&mut self, root: &Path, dir: &Path) {
let Some(prefix) = key(root, dir) else { return };
let prefix = format!("{prefix}/");
self.entries.retain(|k, _| !k.starts_with(&prefix));
}
pub fn save(&self, root: &Path) -> std::io::Result<()> {
let path = root.join(MANIFEST_PATH);
refuse_symlink(root, &path)?;
if self.entries.is_empty() {
return match std::fs::remove_file(&path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
};
}
let body = toml::to_string(&ManifestFile {
files: self.entries.clone(),
})
.map_err(std::io::Error::other)?;
let directory = path
.parent()
.ok_or_else(|| std::io::Error::other("manifest path has no parent directory"))?;
std::fs::create_dir_all(directory)?;
write_atomically(directory, &path, &body)
}
}
fn write_atomically(directory: &Path, path: &Path, body: &str) -> std::io::Result<()> {
use std::io::Write as _;
let mut temp = tempfile::Builder::new()
.prefix(".autumn-generated-")
.suffix(".tmp")
.make_in(directory, |p| {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(p)
})?;
temp.as_file_mut().write_all(body.as_bytes())?;
temp.as_file_mut().sync_all()?;
temp.persist(path).map_err(|e| e.error)?;
Ok(())
}
fn refuse_symlink(root: &Path, path: &Path) -> std::io::Result<()> {
let mut cursor = root.to_path_buf();
for component in MANIFEST_PATH.split('/') {
cursor.push(component);
match std::fs::symlink_metadata(&cursor) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
Ok(m) if m.file_type().is_symlink() => {
return Err(std::io::Error::other(format!(
"{} is a symlink; reading or writing through it could reach \
outside the project",
path.display()
)));
}
Ok(_) => {}
}
}
Ok(())
}
fn key(root: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(root).ok()?;
let mut parts = Vec::new();
for component in relative.components() {
match component {
Component::Normal(part) => parts.push(part.to_str()?),
Component::Prefix(_)
| Component::RootDir
| Component::CurDir
| Component::ParentDir => return None,
}
}
(!parts.is_empty()).then(|| parts.join("/"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> tempfile::TempDir {
tempfile::TempDir::new().unwrap()
}
const OWNER: &str = "model\u{1f}Post\u{1f}title:String";
#[test]
fn records_and_reloads_a_digest() {
let dir = tmp();
let file = dir.path().join("src/models/post.rs");
let mut p = Provenance::default();
p.record(dir.path(), &file, text_digest("// model\n"), OWNER);
p.save(dir.path()).unwrap();
let reloaded = Provenance::load(dir.path());
assert!(reloaded.is_ours(dir.path(), &file, &text_digest("// model\n"), OWNER));
assert!(!reloaded.is_ours(dir.path(), &file, &text_digest("// edited\n"), OWNER));
}
#[test]
fn a_different_command_never_owns_a_recorded_file() {
let dir = tmp();
let file = dir.path().join("src/models/post.rs");
let mut p = Provenance::default();
p.record(dir.path(), &file, text_digest("// model\n"), OWNER);
assert!(!p.is_ours(
dir.path(),
&file,
&text_digest("// model\n"),
"model\u{1f}Post"
));
}
#[test]
fn the_invocation_drops_the_verb_and_the_run_only_flags() {
let normalize = |args: &[&str]| normalize_invocation(args.iter().map(|a| (*a).to_owned()));
assert_eq!(
normalize(&["generate", "model", "Post", "title:String"]),
normalize(&["destroy", "model", "Post", "title:String", "--force"]),
);
assert_eq!(
normalize(&["generate", "model", "Post", "--dry-run"]),
normalize(&["generate", "model", "Post"]),
);
assert_ne!(
normalize(&["generate", "model", "Post", "title:String"]),
normalize(&["destroy", "model", "Post"]),
"omitted fields are different arguments"
);
assert_ne!(
normalize(&["new", "--starter", "saas", "app"]),
normalize(&["destroy", "auth", "User"]),
"a starter scaffold never owns a generator's output"
);
}
#[test]
fn arguments_cannot_collide_by_concatenation() {
let normalize = |args: &[&str]| normalize_invocation(args.iter().map(|a| (*a).to_owned()));
assert_ne!(
normalize(&["model", "Post Tag"]),
normalize(&["model", "Post", "Tag"])
);
}
#[test]
fn crlf_hashes_the_same_as_lf() {
assert_eq!(text_digest("a\r\nb\r\n"), text_digest("a\nb\n"));
}
#[test]
fn bytes_are_hashed_verbatim() {
assert_ne!(bytes_digest(b"a\r\nb"), bytes_digest(b"a\nb"));
}
#[test]
fn an_unrecorded_file_is_never_ours() {
let dir = tmp();
let p = Provenance::default();
assert!(!p.is_ours(
dir.path(),
&dir.path().join("x.rs"),
&text_digest(""),
OWNER
));
}
#[test]
fn a_path_outside_the_project_is_dropped() {
let dir = tmp();
let mut p = Provenance::default();
p.record(dir.path(), Path::new("/etc/passwd"), text_digest(""), OWNER);
assert_eq!(p, Provenance::default());
}
#[test]
fn a_path_escaping_through_parent_segments_is_dropped() {
let dir = tmp();
let mut p = Provenance::default();
p.record(
dir.path(),
&dir.path().join("../secret.toml"),
text_digest(""),
OWNER,
);
assert_eq!(p, Provenance::default());
}
#[test]
fn the_identity_does_not_depend_on_where_the_project_sits() {
let here = tmp();
let there = tmp();
for root in [here.path(), there.path()] {
std::fs::write(
root.join(GENERATE_CONFIG_FILENAME),
"[scaffold.Post]\nfields = [\"title:String\"]\n",
)
.unwrap();
}
assert_eq!(
current_invocation(here.path()),
current_invocation(there.path())
);
}
#[test]
fn a_crlf_checkout_of_the_config_is_the_same_input() {
let lf = tmp();
let crlf = tmp();
std::fs::write(
lf.path().join(GENERATE_CONFIG_FILENAME),
"[scaffold.Post]\nfields = [\"title:String\"]\n",
)
.unwrap();
std::fs::write(
crlf.path().join(GENERATE_CONFIG_FILENAME),
"[scaffold.Post]\r\nfields = [\"title:String\"]\r\n",
)
.unwrap();
assert_eq!(
current_invocation(lf.path()),
current_invocation(crlf.path())
);
}
#[test]
fn the_identity_still_moves_when_the_config_contents_differ() {
let here = tmp();
let there = tmp();
std::fs::write(
here.path().join(GENERATE_CONFIG_FILENAME),
"[scaffold.Post]\n",
)
.unwrap();
std::fs::write(
there.path().join(GENERATE_CONFIG_FILENAME),
"[scaffold.Tag]\n",
)
.unwrap();
assert_ne!(
current_invocation(here.path()),
current_invocation(there.path())
);
}
#[test]
fn an_absent_config_is_distinguishable_from_a_present_one() {
let absent = tmp();
let present = tmp();
std::fs::write(
present.path().join(GENERATE_CONFIG_FILENAME),
"[scaffold.Post]\n",
)
.unwrap();
assert_ne!(
current_invocation(absent.path()),
current_invocation(present.path())
);
}
fn no_db_env<R>(f: impl FnOnce() -> R) -> R {
temp_env::with_vars(
[
("AUTUMN_DATABASE__PRIMARY_URL", None::<&str>),
("AUTUMN_DATABASE__URL", None::<&str>),
("DATABASE_URL", None::<&str>),
],
f,
)
}
#[test]
fn the_identity_moves_with_the_database_backend() {
let sqlite = tmp();
let postgres = tmp();
std::fs::write(
sqlite.path().join("autumn.toml"),
"[database]\nurl = \"sqlite://app.db\"\n",
)
.unwrap();
std::fs::write(
postgres.path().join("autumn.toml"),
"[database]\nurl = \"postgres://localhost/app\"\n",
)
.unwrap();
no_db_env(|| {
assert_ne!(
current_invocation(sqlite.path()),
current_invocation(postgres.path())
);
});
}
#[test]
fn two_projects_on_the_same_backend_share_an_identity() {
let here = tmp();
let there = tmp();
for root in [here.path(), there.path()] {
std::fs::write(
root.join("autumn.toml"),
"[database]\nurl = \"sqlite://app.db\"\n",
)
.unwrap();
}
no_db_env(|| {
assert_eq!(
current_invocation(here.path()),
current_invocation(there.path())
);
});
}
#[test]
fn a_malformed_manifest_reads_as_no_baseline() {
let dir = tmp();
std::fs::create_dir_all(dir.path().join(".autumn")).unwrap();
std::fs::write(dir.path().join(MANIFEST_PATH), "not : toml [[[").unwrap();
assert_eq!(Provenance::load(dir.path()), Provenance::default());
}
#[test]
fn saving_an_empty_manifest_removes_it() {
let dir = tmp();
let file = dir.path().join("a.rs");
let mut p = Provenance::default();
p.record(dir.path(), &file, text_digest("x"), OWNER);
p.save(dir.path()).unwrap();
assert!(dir.path().join(MANIFEST_PATH).is_file());
p.forget(dir.path(), &file);
p.save(dir.path()).unwrap();
assert!(!dir.path().join(MANIFEST_PATH).exists());
}
#[test]
fn forget_dir_drops_only_that_directory() {
let dir = tmp();
let mut p = Provenance::default();
p.record(
dir.path(),
&dir.path().join("migrations/a/up.sql"),
"1".to_owned(),
OWNER,
);
p.record(
dir.path(),
&dir.path().join("migrations/ab/up.sql"),
"2".to_owned(),
OWNER,
);
p.forget_dir(dir.path(), &dir.path().join("migrations/a"));
assert!(!p.contains("migrations/a/up.sql"));
assert!(p.contains("migrations/ab/up.sql"));
}
#[cfg(unix)]
#[test]
fn a_symlinked_manifest_is_refused() {
let dir = tmp();
let outside = tmp();
std::fs::create_dir_all(dir.path().join(".autumn")).unwrap();
std::os::unix::fs::symlink(
outside.path().join("escaped.toml"),
dir.path().join(MANIFEST_PATH),
)
.unwrap();
let mut p = Provenance::default();
p.record(
dir.path(),
&dir.path().join("a.rs"),
text_digest("x"),
OWNER,
);
assert!(p.save(dir.path()).is_err());
assert!(!outside.path().join("escaped.toml").exists());
}
}