///|
pub(all) enum PlanAction {
Quarantine
Skip
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) enum PlanIssueKind {
DuplicateSource
CoveredByParent
DestinationCollision
SourceMissing
SourceChanged
UnsafePath
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) struct PlanItem {
source : String
destination : String
expected_size : Int64
expected_modified_at : Int64
action : PlanAction
rule_id : String
reason : String
risk : RiskLevel
regenerable : Bool
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) struct PlanIssue {
kind : PlanIssueKind
path : String
message : String
} derive(Eq, Debug, ToJson, FromJson)
///|
pub(all) struct CleanupPlan {
format_version : Int
root : String
quarantine_root : String
created_at : Int64
items : Array[PlanItem]
issues : Array[PlanIssue]
reclaimable_size : Int64
} derive(Eq, Debug, ToJson, FromJson)
///|
fn safe_plan_path(path : String) -> Bool {
let normalized = normalize_relative_path(path)
normalized != "" &&
!path.has_prefix("/") &&
!path.has_prefix("\\") &&
!path.contains(":")
}
///|
fn quarantine_destination(path : String) -> String {
normalize_relative_path(path)
}
///|
fn selected_parent(path : String, included : Map[String, Bool]) -> String? {
let mut parent = parent_path(path)
while parent != "" {
if included.get(parent) is Some(_) {
return Some(parent)
}
parent = parent_path(parent)
}
None
}
///|
fn observed_entry_size(entry : Entry, snapshot : Snapshot) -> Int64 {
if entry.is_file() {
entry.size
} else if entry.is_dir() {
match stat_for_directory(snapshot, entry.path) {
Some(stat) => stat.recursive_size
None => 0L
}
} else {
0L
}
}
///|
pub fn build_cleanup_plan(
snapshot : Snapshot,
matches : Array[RuleMatch],
quarantine_root : String,
created_at : Int64,
) -> CleanupPlan {
let items : Array[PlanItem] = []
let issues : Array[PlanIssue] = []
let included : Map[String, Bool] = Map([])
let destinations : Map[String, String] = Map([])
let mut reclaimable = 0L
let ordered = matches.copy()
ordered.sort_by(fn(a, b) {
let a_depth = path_depth(a.path)
let b_depth = path_depth(b.path)
if a_depth < b_depth {
-1
} else if a_depth > b_depth {
1
} else if a.path < b.path {
-1
} else if a.path > b.path {
1
} else {
0
}
})
for candidate in ordered {
if included.get(candidate.path) is Some(_) {
issues.push(PlanIssue::{
kind: DuplicateSource,
path: candidate.path,
message: "More than one cleanup rule selected this path; the first match wins.",
})
continue
}
match selected_parent(candidate.path, included) {
Some(parent) => {
issues.push(PlanIssue::{
kind: CoveredByParent,
path: candidate.path,
message: "The selected parent " +
parent +
" already covers this path.",
})
continue
}
None => ()
}
included[candidate.path] = true
if !safe_plan_path(candidate.path) {
issues.push(PlanIssue::{
kind: UnsafePath,
path: candidate.path,
message: "Cleanup plans only accept non-empty paths relative to the scan root.",
})
continue
}
match snapshot.find(candidate.path) {
None =>
issues.push(PlanIssue::{
kind: SourceMissing,
path: candidate.path,
message: "The candidate is not present in the source snapshot.",
})
Some(entry) => {
let destination = quarantine_destination(candidate.path)
match destinations.get(destination) {
Some(existing) =>
issues.push(PlanIssue::{
kind: DestinationCollision,
path: candidate.path,
message: "Destination is already used by " + existing + ".",
})
None => {
destinations[destination] = candidate.path
items.push(PlanItem::{
source: candidate.path,
destination,
expected_size: candidate.size,
expected_modified_at: entry.modified_at,
action: Quarantine,
rule_id: candidate.rule_id,
reason: candidate.reason,
risk: candidate.risk,
regenerable: candidate.regenerable,
})
reclaimable += candidate.size
}
}
}
}
}
CleanupPlan::{
format_version: 1,
root: snapshot.root,
quarantine_root,
created_at,
items,
issues,
reclaimable_size: reclaimable,
}
}
///|
pub fn validate_plan_against_snapshot(
plan : CleanupPlan,
current : Snapshot,
) -> Array[PlanIssue] {
let issues : Array[PlanIssue] = []
for item in plan.items {
match current.find(item.source) {
None =>
issues.push(PlanIssue::{
kind: SourceMissing,
path: item.source,
message: "The source no longer exists.",
})
Some(entry) =>
if observed_entry_size(entry, current) != item.expected_size ||
entry.modified_at != item.expected_modified_at {
issues.push(PlanIssue::{
kind: SourceChanged,
path: item.source,
message: "The source changed after the cleanup plan was created.",
})
}
}
}
issues
}
///|
pub fn CleanupPlan::low_risk_size(self : CleanupPlan) -> Int64 {
let mut total = 0L
for item in self.items {
if item.risk == Low {
total += item.expected_size
}
}
total
}
///|
pub fn CleanupPlan::high_risk_count(self : CleanupPlan) -> Int {
self.items.filter(fn(item) { item.risk == High }).length()
}
///|
pub fn plan_to_json(plan : CleanupPlan, pretty? : Bool = true) -> String {
let json = ToJson::to_json(plan)
if pretty {
json.stringify(indent=2)
} else {
json.stringify()
}
}
///|
pub fn plan_from_json(source : String) -> CleanupPlan raise {
let plan : CleanupPlan = @json.from_json(@json.parse(source))
if plan.format_version != 1 {
raise Failure("Unsupported cleanup plan version")
}
plan
}
///|
pub async fn write_plan(path : String, plan : CleanupPlan) -> Unit {
@fs.write_file(path, plan_to_json(plan))
}
///|
pub async fn read_plan(path : String) -> CleanupPlan {
plan_from_json(@fs.read_file(path).text())
}