1 /*
2  * Copyright (C) 2012 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.internal.util;
18 
19 import android.annotation.NonNull;
20 import android.os.FileUtils;
21 import android.util.Log;
22 import android.util.Pair;
23 
24 import libcore.io.IoUtils;
25 
26 import java.io.BufferedInputStream;
27 import java.io.BufferedOutputStream;
28 import java.io.File;
29 import java.io.FileInputStream;
30 import java.io.FileOutputStream;
31 import java.io.IOException;
32 import java.io.InputStream;
33 import java.io.OutputStream;
34 import java.util.Comparator;
35 import java.util.Objects;
36 import java.util.TreeSet;
37 import java.util.zip.ZipEntry;
38 import java.util.zip.ZipOutputStream;
39 
40 /**
41  * Utility that rotates files over time, similar to {@code logrotate}. There is
42  * a single "active" file, which is periodically rotated into historical files,
43  * and eventually deleted entirely. Files are stored under a specific directory
44  * with a well-known prefix.
45  * <p>
46  * Instead of manipulating files directly, users implement interfaces that
47  * perform operations on {@link InputStream} and {@link OutputStream}. This
48  * enables atomic rewriting of file contents in
49  * {@link #rewriteActive(Rewriter, long)}.
50  * <p>
51  * Users must periodically call {@link #maybeRotate(long)} to perform actual
52  * rotation. Not inherently thread safe.
53  *
54  * @hide
55  */
56 // Exported to Mainline modules; cannot use annotations
57 // @android.ravenwood.annotation.RavenwoodKeepWholeClass
58 public class FileRotator {
59     private static final String TAG = "FileRotator";
60     private static final boolean LOGD = false;
61 
62     private final File mBasePath;
63     private final String mPrefix;
64     private final long mRotateAgeMillis;
65     private final long mDeleteAgeMillis;
66 
67     private static final String SUFFIX_BACKUP = ".backup";
68     private static final String SUFFIX_NO_BACKUP = ".no_backup";
69 
70     // TODO: provide method to append to active file
71 
72     /**
73      * External class that reads data from a given {@link InputStream}. May be
74      * called multiple times when reading rotated data.
75      */
76     public interface Reader {
read(InputStream in)77         public void read(InputStream in) throws IOException;
78     }
79 
80     /**
81      * External class that writes data to a given {@link OutputStream}.
82      */
83     public interface Writer {
write(OutputStream out)84         public void write(OutputStream out) throws IOException;
85     }
86 
87     /**
88      * External class that reads existing data from given {@link InputStream},
89      * then writes any modified data to {@link OutputStream}.
90      */
91     public interface Rewriter extends Reader, Writer {
reset()92         public void reset();
shouldWrite()93         public boolean shouldWrite();
94     }
95 
96     /**
97      * Create a file rotator.
98      *
99      * @param basePath Directory under which all files will be placed.
100      * @param prefix Filename prefix used to identify this rotator.
101      * @param rotateAgeMillis Age in milliseconds beyond which an active file
102      *            may be rotated into a historical file.
103      * @param deleteAgeMillis Age in milliseconds beyond which a rotated file
104      *            may be deleted.
105      */
FileRotator(File basePath, String prefix, long rotateAgeMillis, long deleteAgeMillis)106     public FileRotator(File basePath, String prefix, long rotateAgeMillis, long deleteAgeMillis) {
107         mBasePath = Objects.requireNonNull(basePath);
108         mPrefix = Objects.requireNonNull(prefix);
109         mRotateAgeMillis = rotateAgeMillis;
110         mDeleteAgeMillis = deleteAgeMillis;
111 
112         // ensure that base path exists
113         mBasePath.mkdirs();
114 
115         // recover any backup files
116         for (String name : mBasePath.list()) {
117             if (!name.startsWith(mPrefix)) continue;
118 
119             if (name.endsWith(SUFFIX_BACKUP)) {
120                 if (LOGD) Log.d(TAG, "recovering " + name);
121 
122                 final File backupFile = new File(mBasePath, name);
123                 final File file = new File(
124                         mBasePath, name.substring(0, name.length() - SUFFIX_BACKUP.length()));
125 
126                 // write failed with backup; recover last file
127                 backupFile.renameTo(file);
128 
129             } else if (name.endsWith(SUFFIX_NO_BACKUP)) {
130                 if (LOGD) Log.d(TAG, "recovering " + name);
131 
132                 final File noBackupFile = new File(mBasePath, name);
133                 final File file = new File(
134                         mBasePath, name.substring(0, name.length() - SUFFIX_NO_BACKUP.length()));
135 
136                 // write failed without backup; delete both
137                 noBackupFile.delete();
138                 file.delete();
139             }
140         }
141     }
142 
143     /**
144      * Delete all files managed by this rotator.
145      */
deleteAll()146     public void deleteAll() {
147         final FileInfo info = new FileInfo(mPrefix);
148         for (String name : mBasePath.list()) {
149             if (info.parse(name)) {
150                 // delete each file that matches parser
151                 new File(mBasePath, name).delete();
152             }
153         }
154     }
155 
156     /**
157      * Dump all files managed by this rotator for debugging purposes.
158      */
dumpAll(OutputStream os)159     public void dumpAll(OutputStream os) throws IOException {
160         final ZipOutputStream zos = new ZipOutputStream(os);
161         try {
162             final FileInfo info = new FileInfo(mPrefix);
163             for (String name : mBasePath.list()) {
164                 if (info.parse(name)) {
165                     final ZipEntry entry = new ZipEntry(name);
166                     zos.putNextEntry(entry);
167 
168                     final File file = new File(mBasePath, name);
169                     final FileInputStream is = new FileInputStream(file);
170                     try {
171                         FileUtils.copy(is, zos);
172                     } finally {
173                         IoUtils.closeQuietly(is);
174                     }
175 
176                     zos.closeEntry();
177                 }
178             }
179         } finally {
180             IoUtils.closeQuietly(zos);
181         }
182     }
183 
184     /**
185      * Process currently active file, first reading any existing data, then
186      * writing modified data. Maintains a backup during write, which is restored
187      * if the write fails.
188      */
rewriteActive(Rewriter rewriter, long currentTimeMillis)189     public void rewriteActive(Rewriter rewriter, long currentTimeMillis)
190             throws IOException {
191         final String activeName = getActiveName(currentTimeMillis);
192         rewriteSingle(rewriter, activeName);
193     }
194 
195     @Deprecated
combineActive(final Reader reader, final Writer writer, long currentTimeMillis)196     public void combineActive(final Reader reader, final Writer writer, long currentTimeMillis)
197             throws IOException {
198         rewriteActive(new Rewriter() {
199             @Override
200             public void reset() {
201                 // ignored
202             }
203 
204             @Override
205             public void read(InputStream in) throws IOException {
206                 reader.read(in);
207             }
208 
209             @Override
210             public boolean shouldWrite() {
211                 return true;
212             }
213 
214             @Override
215             public void write(OutputStream out) throws IOException {
216                 writer.write(out);
217             }
218         }, currentTimeMillis);
219     }
220 
221     /**
222      * Process all files managed by this rotator, usually to rewrite historical
223      * data. Each file is processed atomically.
224      */
rewriteAll(Rewriter rewriter)225     public void rewriteAll(Rewriter rewriter) throws IOException {
226         final FileInfo info = new FileInfo(mPrefix);
227         for (String name : mBasePath.list()) {
228             if (!info.parse(name)) continue;
229 
230             // process each file that matches parser
231             rewriteSingle(rewriter, name);
232         }
233     }
234 
235     /**
236      * Process a single file atomically, first reading any existing data, then
237      * writing modified data. Maintains a backup during write, which is restored
238      * if the write fails.
239      */
rewriteSingle(Rewriter rewriter, String name)240     private void rewriteSingle(Rewriter rewriter, String name) throws IOException {
241         if (LOGD) Log.d(TAG, "rewriting " + name);
242 
243         final File file = new File(mBasePath, name);
244         final File backupFile;
245 
246         rewriter.reset();
247 
248         if (file.exists()) {
249             // read existing data
250             readFile(file, rewriter);
251 
252             // skip when rewriter has nothing to write
253             if (!rewriter.shouldWrite()) return;
254 
255             // backup existing data during write
256             backupFile = new File(mBasePath, name + SUFFIX_BACKUP);
257             file.renameTo(backupFile);
258 
259             try {
260                 writeFile(file, rewriter);
261 
262                 // write success, delete backup
263                 backupFile.delete();
264             } catch (Throwable t) {
265                 // write failed, delete file and restore backup
266                 file.delete();
267                 backupFile.renameTo(file);
268                 throw rethrowAsIoException(t);
269             }
270 
271         } else {
272             // create empty backup during write
273             backupFile = new File(mBasePath, name + SUFFIX_NO_BACKUP);
274             backupFile.createNewFile();
275 
276             try {
277                 writeFile(file, rewriter);
278 
279                 // write success, delete empty backup
280                 backupFile.delete();
281             } catch (Throwable t) {
282                 // write failed, delete file and empty backup
283                 file.delete();
284                 backupFile.delete();
285                 throw rethrowAsIoException(t);
286             }
287         }
288     }
289 
290     /**
291      * Process a single file atomically, with the given start and end timestamps.
292      * If a file with these exact start and end timestamps does not exist, a new
293      * empty file will be written.
294      */
rewriteSingle(@onNull Rewriter rewriter, long startTimeMillis, long endTimeMillis)295     public void rewriteSingle(@NonNull Rewriter rewriter, long startTimeMillis, long endTimeMillis)
296             throws IOException {
297         final FileInfo info = new FileInfo(mPrefix);
298 
299         info.startMillis = startTimeMillis;
300         info.endMillis = endTimeMillis;
301         rewriteSingle(rewriter, info.build());
302     }
303 
304     /**
305      * Read any rotated data that overlap the requested time range.
306      */
readMatching(Reader reader, long matchStartMillis, long matchEndMillis)307     public void readMatching(Reader reader, long matchStartMillis, long matchEndMillis)
308             throws IOException {
309         final FileInfo info = new FileInfo(mPrefix);
310         final TreeSet<Pair<Long, String>> readSet = new TreeSet<>(
311                 Comparator.comparingLong(o -> o.first));
312         for (String name : mBasePath.list()) {
313             if (!info.parse(name)) continue;
314 
315             // Add file to set when it overlaps.
316             if (info.startMillis <= matchEndMillis && matchStartMillis <= info.endMillis) {
317                 readSet.add(new Pair(info.startMillis, name));
318             }
319         }
320 
321         // Read files in ascending order of start timestamp.
322         for (Pair<Long, String> pair : readSet) {
323             final String name = pair.second;
324             if (LOGD) Log.d(TAG, "reading matching " + name);
325             final File file = new File(mBasePath, name);
326             readFile(file, reader);
327         }
328     }
329 
330     /**
331      * Return the currently active file, which may not exist yet.
332      */
getActiveName(long currentTimeMillis)333     private String getActiveName(long currentTimeMillis) {
334         String oldestActiveName = null;
335         long oldestActiveStart = Long.MAX_VALUE;
336 
337         final FileInfo info = new FileInfo(mPrefix);
338         for (String name : mBasePath.list()) {
339             if (!info.parse(name)) continue;
340 
341             // pick the oldest active file which covers current time
342             if (info.isActive() && info.startMillis < currentTimeMillis
343                     && info.startMillis < oldestActiveStart) {
344                 oldestActiveName = name;
345                 oldestActiveStart = info.startMillis;
346             }
347         }
348 
349         if (oldestActiveName != null) {
350             return oldestActiveName;
351         } else {
352             // no active file found above; create one starting now
353             info.startMillis = currentTimeMillis;
354             info.endMillis = Long.MAX_VALUE;
355             return info.build();
356         }
357     }
358 
359     /**
360      * Examine all files managed by this rotator, renaming or deleting if their
361      * age matches the configured thresholds.
362      */
maybeRotate(long currentTimeMillis)363     public void maybeRotate(long currentTimeMillis) {
364         final long rotateBefore = currentTimeMillis - mRotateAgeMillis;
365         final long deleteBefore = currentTimeMillis - mDeleteAgeMillis;
366 
367         final FileInfo info = new FileInfo(mPrefix);
368         String[] baseFiles = mBasePath.list();
369         if (baseFiles == null) {
370             return;
371         }
372 
373         for (String name : baseFiles) {
374             if (!info.parse(name)) continue;
375 
376             if (info.isActive()) {
377                 if (info.startMillis <= rotateBefore) {
378                     // found active file; rotate if old enough
379                     if (LOGD) Log.d(TAG, "rotating " + name);
380 
381                     info.endMillis = currentTimeMillis;
382 
383                     final File file = new File(mBasePath, name);
384                     final File destFile = new File(mBasePath, info.build());
385                     file.renameTo(destFile);
386                 }
387             } else if (info.endMillis <= deleteBefore) {
388                 // found rotated file; delete if old enough
389                 if (LOGD) Log.d(TAG, "deleting " + name);
390 
391                 final File file = new File(mBasePath, name);
392                 file.delete();
393             }
394         }
395     }
396 
readFile(File file, Reader reader)397     private static void readFile(File file, Reader reader) throws IOException {
398         final FileInputStream fis = new FileInputStream(file);
399         final BufferedInputStream bis = new BufferedInputStream(fis);
400         try {
401             reader.read(bis);
402         } finally {
403             IoUtils.closeQuietly(bis);
404         }
405     }
406 
writeFile(File file, Writer writer)407     private static void writeFile(File file, Writer writer) throws IOException {
408         final FileOutputStream fos = new FileOutputStream(file);
409         final BufferedOutputStream bos = new BufferedOutputStream(fos);
410         try {
411             writer.write(bos);
412             bos.flush();
413         } finally {
414             try {
415                 fos.getFD().sync();
416             } catch (IOException e) {
417             }
418             IoUtils.closeQuietly(bos);
419         }
420     }
421 
rethrowAsIoException(Throwable t)422     private static IOException rethrowAsIoException(Throwable t) throws IOException {
423         if (t instanceof IOException) {
424             throw (IOException) t;
425         } else {
426             throw new IOException(t.getMessage(), t);
427         }
428     }
429 
430     /**
431      * Details for a rotated file, either parsed from an existing filename, or
432      * ready to be built into a new filename.
433      */
434     private static class FileInfo {
435         public final String prefix;
436 
437         public long startMillis;
438         public long endMillis;
439 
FileInfo(String prefix)440         public FileInfo(String prefix) {
441             this.prefix = Objects.requireNonNull(prefix);
442         }
443 
444         /**
445          * Attempt parsing the given filename.
446          *
447          * @return Whether parsing was successful.
448          */
parse(String name)449         public boolean parse(String name) {
450             startMillis = endMillis = -1;
451 
452             final int dotIndex = name.lastIndexOf('.');
453             final int dashIndex = name.lastIndexOf('-');
454 
455             // skip when missing time section
456             if (dotIndex == -1 || dashIndex == -1) return false;
457 
458             // skip when prefix doesn't match
459             if (!prefix.equals(name.substring(0, dotIndex))) return false;
460 
461             try {
462                 startMillis = Long.parseLong(name.substring(dotIndex + 1, dashIndex));
463 
464                 if (name.length() - dashIndex == 1) {
465                     endMillis = Long.MAX_VALUE;
466                 } else {
467                     endMillis = Long.parseLong(name.substring(dashIndex + 1));
468                 }
469 
470                 return true;
471             } catch (NumberFormatException e) {
472                 return false;
473             }
474         }
475 
476         /**
477          * Build current state into filename.
478          */
build()479         public String build() {
480             final StringBuilder name = new StringBuilder();
481             name.append(prefix).append('.').append(startMillis).append('-');
482             if (endMillis != Long.MAX_VALUE) {
483                 name.append(endMillis);
484             }
485             return name.toString();
486         }
487 
488         /**
489          * Test if current file is active (no end timestamp).
490          */
isActive()491         public boolean isActive() {
492             return endMillis == Long.MAX_VALUE;
493         }
494     }
495 }
496