thirdshift 0.20.5

Turn a GitHub issue into a ready-for-review pull request, unattended
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
//! The Log inventory: one capture of the retained Command logs and Session
//! traces beneath the log folder. Command reading, historical recovery,
//! Session queries and the Logs listing share a capture, so repeated queries
//! reuse its enumeration, filename decoding, Model evidence and modification
//! times. The next request captures afresh; Readers check eligibility alone.

use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::SystemTime;

use anyhow::{Context, Result};
use chrono::{DateTime, FixedOffset};

use crate::logs::owner::{self, SessionOwner};

/// The filesystem effects of discovery, separable so tests can count them.
pub(super) trait Files: Send + Sync {
    /// A directory's entries, each with its own type: symlinks are `Other`.
    fn list(&self, path: &Path) -> io::Result<Vec<(PathBuf, EntryKind)>>;
    fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata>;
    fn read(&self, path: &Path) -> io::Result<Vec<u8>>;

    /// The canonical log directory another account's reads must stay under.
    /// Absent for the serving account. Callers must not resolve it again.
    fn accepted_directory(&self) -> Option<PathBuf> {
        None
    }
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum EntryKind {
    File,
    Directory,
    Other,
}

pub(super) struct Native;

impl Files for Native {
    fn list(&self, path: &Path) -> io::Result<Vec<(PathBuf, EntryKind)>> {
        std::fs::read_dir(path)?
            .map(|entry| {
                let entry = entry?;
                let kind = entry.file_type()?;
                let kind = if kind.is_file() {
                    EntryKind::File
                } else if kind.is_dir() {
                    EntryKind::Directory
                } else {
                    EntryKind::Other
                };
                Ok((entry.path(), kind))
            })
            .collect()
    }

    fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata> {
        std::fs::metadata(path)
    }

    fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
        std::fs::read(path)
    }
}

/// `root` is an accepted directory's canonical path. It is not resolved again.
pub(super) fn stays_under(root: &Path, path: &Path) -> bool {
    path.canonicalize()
        .is_ok_and(|canonical| canonical.starts_with(root))
}

/// Other-account discovery. A `commands` alias may exist, but nothing it
/// reaches outside the accepted directory is listed or read.
pub(super) struct Confined {
    root: PathBuf,
}

impl Confined {
    pub(super) fn new(root: PathBuf) -> Self {
        Self { root }
    }

    fn outside() -> io::Error {
        io::Error::new(io::ErrorKind::NotFound, "outside accepted log directory")
    }
}

impl Files for Confined {
    fn accepted_directory(&self) -> Option<PathBuf> {
        Some(self.root.clone())
    }

    fn list(&self, path: &Path) -> io::Result<Vec<(PathBuf, EntryKind)>> {
        if !stays_under(&self.root, path) {
            return Err(Self::outside());
        }
        Ok(Files::list(&Native, path)?
            .into_iter()
            .filter(|(entry, _)| stays_under(&self.root, entry))
            .collect())
    }

    fn metadata(&self, path: &Path) -> io::Result<std::fs::Metadata> {
        if !stays_under(&self.root, path) {
            return Err(Self::outside());
        }
        Files::metadata(&Native, path)
    }

    fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
        if !stays_under(&self.root, path) {
            return Err(Self::outside());
        }
        Files::read(&Native, path)
    }
}

pub(super) struct Inventory {
    files: Arc<dyn Files>,
    /// The canonical log directory discovery accepted for another account.
    /// Absent for the serving account. It is not resolved again.
    accepted: Option<PathBuf>,
    repositories: Vec<Repository>,
    /// Kind folders by lexical path, plus any folder a Session query named
    /// outside the listed layout, each enumerated once.
    folders: Mutex<HashMap<PathBuf, Arc<Folder>>>,
    /// Session ordering: best effort, with UNIX_EPOCH when unavailable.
    modified: Mutex<HashMap<PathBuf, SystemTime>>,
    /// Activity is sampled on first use, including unavailable metadata.
    activity: Mutex<HashMap<PathBuf, FileActivity>>,
}

#[derive(Clone, Copy, Default)]
pub(super) struct FileActivity {
    pub(super) length: u64,
    pub(super) modified: Option<SystemTime>,
}

/// An `owner/repository` folder and its real `commands/<kind>` folders.
pub(super) struct Repository {
    pub(super) path: PathBuf,
    pub(super) folders: Vec<Arc<Folder>>,
}

impl Repository {
    /// `owner/repository`, from the folder names as spelled.
    pub(super) fn name(&self) -> String {
        name(&self.path)
    }
}

pub(super) fn name(repo: &Path) -> String {
    format!(
        "{}/{}",
        repo.parent()
            .and_then(Path::file_name)
            .unwrap_or_default()
            .to_string_lossy(),
        repo.file_name().unwrap_or_default().to_string_lossy()
    )
}

pub(super) struct Folder {
    pub(super) path: PathBuf,
    /// Regular files only, never symlinks, in enumeration order.
    pub(super) files: Vec<PathBuf>,
    sessions: OnceLock<Sessions>,
}

/// Inferred Session candidates, indexed by subject and effective owner.
struct Sessions {
    candidates: Vec<Candidate>,
    index: HashMap<(String, SessionOwner), Vec<usize>>,
}

/// A Session trace's filename facts and optional Model evidence, decoded once.
pub(super) struct Candidate {
    pub(super) path: PathBuf,
    pub(super) subject: String,
    pub(super) time: DateTime<FixedOffset>,
    owner: SessionOwner,
    named_log: PathBuf,
}

impl Candidate {
    /// The evidenced Command log, otherwise the one its filename names.
    pub(super) fn command_log(&self) -> &Path {
        match &self.owner {
            SessionOwner::Evidenced(log) => log,
            SessionOwner::Named { .. } => &self.named_log,
        }
    }

    pub(super) fn owned_by(&self, log: &Path) -> bool {
        self.owner.owns(log)
    }
}

impl Inventory {
    fn new(files: Arc<dyn Files>) -> Self {
        let accepted = files.accepted_directory();
        Self {
            files,
            accepted,
            repositories: Vec::new(),
            folders: Mutex::new(HashMap::new()),
            modified: Mutex::new(HashMap::new()),
            activity: Mutex::new(HashMap::new()),
        }
    }

    /// The canonical directory another account's Command log must stay under.
    pub(super) fn accepted_directory(&self) -> Option<&Path> {
        self.accepted.as_deref()
    }

    /// Only the root and the literal `commands` folder may be aliases; owner,
    /// repository and kind entries must be real directories.
    pub(super) fn capture(files: Arc<dyn Files>, root: &Path) -> Result<Self> {
        let mut inventory = Self::new(files);
        for owner in inventory.directories(root)? {
            for path in inventory.directories(&owner)? {
                let mut folders = Vec::new();
                for folder in inventory.directories(&path.join("commands"))? {
                    folders.push(inventory.folder(&folder)?);
                }
                inventory.repositories.push(Repository { path, folders });
            }
        }
        Ok(inventory)
    }

    /// A capture holding just the folders its queries name, such as one
    /// Command's folder reloaded after Stop.
    pub(super) fn on_demand() -> Self {
        Self::new(Arc::new(Native))
    }

    pub(super) fn repositories(&self) -> &[Repository] {
        &self.repositories
    }

    fn list(&self, path: &Path) -> Result<Vec<(PathBuf, EntryKind)>> {
        match self.files.list(path) {
            Ok(entries) => Ok(entries),
            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
            Err(error) => Err(error).with_context(|| format!("could not list {}", path.display())),
        }
    }

    pub(super) fn directories(&self, path: &Path) -> Result<Vec<PathBuf>> {
        Ok(self
            .list(path)?
            .into_iter()
            .filter(|(_, kind)| *kind == EntryKind::Directory)
            .map(|(path, _)| path)
            .collect())
    }

    /// The captured folder at this lexical path, enumerating it on first use.
    pub(super) fn folder(&self, path: &Path) -> Result<Arc<Folder>> {
        if let Some(folder) = self.folders.lock().unwrap().get(path) {
            return Ok(folder.clone());
        }
        let folder = Arc::new(Folder {
            path: path.to_owned(),
            files: self
                .list(path)?
                .into_iter()
                .filter(|(_, kind)| *kind == EntryKind::File)
                .map(|(path, _)| path)
                .collect(),
            sessions: OnceLock::new(),
        });
        Ok(self
            .folders
            .lock()
            .unwrap()
            .entry(path.to_owned())
            .or_insert(folder)
            .clone())
    }

    fn sessions<'f>(&self, folder: &'f Folder) -> &'f Sessions {
        folder.sessions.get_or_init(|| {
            let mut sessions = Sessions {
                candidates: Vec::new(),
                index: HashMap::new(),
            };
            for path in &folder.files {
                if !owner::inventory_session(path) {
                    continue;
                }
                let Some(decoded) =
                    owner::decode(path).filter(|decoded| decoded.session_kind.is_some())
                else {
                    continue;
                };
                // Unreadable or absent evidence falls back to the filename.
                let evidence = self
                    .files
                    .read(&owner::evidence_file(path))
                    .ok()
                    .and_then(|sidecar| owner::evidenced_log(&sidecar));
                let candidate = Candidate {
                    path: path.clone(),
                    subject: decoded.subject.into(),
                    time: decoded.time(),
                    named_log: decoded.command_log(path),
                    owner: SessionOwner::of(&decoded, evidence),
                };
                sessions
                    .index
                    .entry((candidate.subject.clone(), candidate.owner.clone()))
                    .or_default()
                    .push(sessions.candidates.len());
                // Catalogue reviews retain their Security review kind, with
                // the Process Step in the subject to keep every trace. Also
                // discover them through their Process or Review run subject, with
                // exactly the same owner constraint as every other Session.
                if candidate
                    .subject
                    .strip_prefix("process-step-")
                    .and_then(|step| step.parse::<usize>().ok())
                    .is_some_and(|step| step > 0)
                {
                    for subject in ["process", "review"] {
                        sessions
                            .index
                            .entry((subject.to_string(), candidate.owner.clone()))
                            .or_default()
                            .push(sessions.candidates.len());
                    }
                }
                sessions.candidates.push(candidate);
            }
            sessions
        })
    }

    /// Every Session candidate in a captured folder, in enumeration order.
    pub(super) fn candidates<'f>(&self, folder: &'f Folder) -> &'f [Candidate] {
        &self.sessions(folder).candidates
    }

    /// Discovered Sessions beside `log` that name `subject` and belong to it.
    pub(super) fn sessions_of(&self, log: &Path, subject: &str) -> Result<Vec<PathBuf>> {
        let folder = self.folder(log.parent().context("Command log has no directory")?)?;
        let sessions = self.sessions(&folder);
        Ok(SessionOwner::of_log(log)
            .filter_map(|owner| sessions.index.get(&(subject.to_owned(), owner)))
            .flatten()
            .map(|&index| sessions.candidates[index].path.clone())
            .collect())
    }

    /// Order Sessions by modification time, then exact path, ascending.
    pub(super) fn ordered(&self, paths: Vec<PathBuf>) -> Vec<PathBuf> {
        let mut modified = self.modified.lock().unwrap();
        let mut dated: Vec<_> = paths
            .into_iter()
            .map(|path| {
                let time = *modified.entry(path.clone()).or_insert_with(|| {
                    self.files
                        .metadata(&path)
                        .and_then(|metadata| metadata.modified())
                        .unwrap_or(SystemTime::UNIX_EPOCH)
                });
                (time, path)
            })
            .collect();
        dated.sort();
        dated.into_iter().map(|(_, path)| path).collect()
    }

    pub(super) fn modified(&self, path: &Path) -> Result<Option<SystemTime>> {
        Ok(self.files.metadata(path)?.modified().ok())
    }

    pub(super) fn activity(&self, path: &Path) -> FileActivity {
        *self
            .activity
            .lock()
            .unwrap()
            .entry(path.to_owned())
            .or_insert_with(|| {
                self.files.metadata(path).map_or_else(
                    |_| FileActivity::default(),
                    |file| FileActivity {
                        length: file.len(),
                        modified: file.modified().ok(),
                    },
                )
            })
    }

    pub(super) fn is_file(&self, path: &Path) -> bool {
        self.files
            .metadata(path)
            .is_ok_and(|metadata| metadata.is_file())
    }
}