use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[non_exhaustive]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(from = "LockEntryRepr")]
pub struct LockEntry {
pub id: String,
pub path: String,
pub sha: String,
pub branch: String,
pub installed_at: DateTime<Utc>,
pub actions_hash: String,
pub schema_version: String,
#[serde(default)]
pub synthetic: bool,
}
#[derive(Deserialize)]
struct LockEntryRepr {
id: String,
#[serde(default)]
path: Option<String>,
sha: String,
branch: String,
installed_at: DateTime<Utc>,
actions_hash: String,
schema_version: String,
#[serde(default)]
synthetic: bool,
}
impl From<LockEntryRepr> for LockEntry {
fn from(r: LockEntryRepr) -> Self {
let path = r.path.unwrap_or_else(|| r.id.clone());
Self {
id: r.id,
path,
sha: r.sha,
branch: r.branch,
installed_at: r.installed_at,
actions_hash: r.actions_hash,
schema_version: r.schema_version,
synthetic: r.synthetic,
}
}
}
impl LockEntry {
#[must_use]
pub fn new(
id: impl Into<String>,
sha: impl Into<String>,
branch: impl Into<String>,
installed_at: DateTime<Utc>,
actions_hash: impl Into<String>,
schema_version: impl Into<String>,
) -> Self {
let id = id.into();
let path = id.clone();
Self {
id,
path,
sha: sha.into(),
branch: branch.into(),
installed_at,
actions_hash: actions_hash.into(),
schema_version: schema_version.into(),
synthetic: false,
}
}
pub fn validate_path(path: &str) -> Result<(), LockfileError> {
if path.is_empty() {
return Err(LockfileError::InvalidPath {
path: path.to_string(),
reason: "path must not be empty",
});
}
if path.contains('\\') {
return Err(LockfileError::InvalidPath {
path: path.to_string(),
reason: "path must use POSIX `/` separator (no `\\`)",
});
}
if path.starts_with('/') {
return Err(LockfileError::InvalidPath {
path: path.to_string(),
reason: "path must be parent-relative (no leading `/`)",
});
}
if path.len() >= 2 {
let mut chars = path.chars();
let c0 = chars.next().unwrap();
let c1 = chars.next().unwrap();
if c0.is_ascii_alphabetic() && c1 == ':' {
return Err(LockfileError::InvalidPath {
path: path.to_string(),
reason: "path must be parent-relative (no drive prefix)",
});
}
}
for segment in path.split('/') {
if segment == ".." {
return Err(LockfileError::InvalidPath {
path: path.to_string(),
reason: "path must not contain `..` segments",
});
}
}
Ok(())
}
}
#[derive(Debug, Error)]
pub enum LockfileError {
#[error("lockfile i/o error: {0}")]
Io(#[from] std::io::Error),
#[error("lockfile corrupted at line {line}: {source}")]
Corruption {
line: usize,
#[source]
source: serde_json::Error,
},
#[error("lockfile serialize error: {0}")]
Serialize(serde_json::Error),
#[error("invalid lockfile entry path `{path}`: {reason}")]
InvalidPath {
path: String,
reason: &'static str,
},
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
fn ts() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 4, 27, 10, 0, 0).unwrap()
}
fn sample(id: &str, path: &str) -> LockEntry {
let mut e = LockEntry::new(id, "deadbeef", "main", ts(), "h", "1");
e.path = path.into();
e
}
#[test]
fn test_lockentry_path_field_round_trip() {
let entry = sample("nested-child", "subdir/nested-child");
let line = serde_json::to_string(&entry).expect("serialize");
assert!(
line.contains(r#""path":"subdir/nested-child""#),
"serialized form must carry explicit path field, got: {line}"
);
let back: LockEntry = serde_json::from_str(&line).expect("deserialize");
assert_eq!(back, entry);
assert_eq!(back.path, "subdir/nested-child");
}
#[test]
fn test_lockentry_v1_1_1_read_fallback() {
let line = r#"{"id":"alpha","sha":"abc","branch":"main","installed_at":"2026-04-27T10:00:00Z","actions_hash":"h","schema_version":"1"}"#;
let entry: LockEntry = serde_json::from_str(line).expect("v1.1.1 line must deserialize");
assert_eq!(entry.id, "alpha");
assert_eq!(
entry.path, "alpha",
"missing path must be derived from id for v1.1.1 lockfiles",
);
assert!(!entry.synthetic);
}
#[test]
fn test_lockentry_v1_1_1_read_fallback_synthetic() {
let line = r#"{"id":"plain-git","sha":"deadbeef","branch":"main","installed_at":"2026-04-27T10:00:00Z","actions_hash":"","schema_version":"1","synthetic":true}"#;
let entry: LockEntry =
serde_json::from_str(line).expect("v1.1.1 synthetic must deserialize");
assert_eq!(entry.path, "plain-git");
assert!(entry.synthetic);
}
#[test]
fn test_lockentry_path_validation_rejects_parent_traversal() {
assert!(LockEntry::validate_path("../escape").is_err());
assert!(LockEntry::validate_path("foo/../bar").is_err());
assert!(LockEntry::validate_path("..").is_err());
}
#[test]
fn test_lockentry_path_validation_rejects_absolute() {
assert!(LockEntry::validate_path("/foo").is_err());
assert!(LockEntry::validate_path("/").is_err());
assert!(LockEntry::validate_path("C:/foo").is_err());
assert!(LockEntry::validate_path("C:\\foo").is_err());
}
#[test]
fn test_lockentry_path_must_be_posix_separator() {
assert!(LockEntry::validate_path("foo\\bar").is_err());
assert!(LockEntry::validate_path("foo/bar").is_ok());
assert!(LockEntry::validate_path("plain-git-child").is_ok());
assert!(LockEntry::validate_path("a/b/c").is_ok());
}
#[test]
fn test_lockentry_path_validation_rejects_empty() {
assert!(LockEntry::validate_path("").is_err());
}
}